Compare commits

...
Author SHA1 Message Date
Mike Schwarz 7f7dc9720c fix(queue): rig queue show names a handed-off row's successor (#2874)
GET /api/queue/:id, and with it rig queue show and the MCP show tool for unscoped callers, now names a handed-off item's successors: the rows whose handedOffFrom is this item, plus a local row its closure target names, each with its id, destination, state and how it was found. When none is named it adds a note: a cross-host target as recorded, with no remote lookup; that no row on this daemon was made by the handoff; or that the lookup couldn't be done, and the read still answers. Past 20 linked successors the newest 20 are named and successorsTruncated is set. Callers with a scoped MCP grant don't receive successors, successorsTruncated or successorNote; the existing scope-aware singular successor is unchanged. Nothing stored changes; closure_target keeps recording the new owner as documented. Migration 159 adds a partial index on handed_off_from for the lookup. Reported by @XBS9 in #2794, which this refers to.
2026-11-09 18:56:06 -08:00
Mike Schwarz be1b910a4b Let the active terminal size writable agent tiles (#2868)
Writable terminal tiles use the published 0.7.10 plain tmux attach again, replacing the captured view that only attached while someone typed: the active terminal sizes the agent's PTY under tmux's own client-sizing policy, so text reflows on every resize and switching devices follows the screen in use. Read-only tiles keep their observer behaviour. Readable-capture, layout and browser pins release by unsetting the window's local window-size instead of writing back a remembered value, and a failed or unknown pin record leaves no manual pin behind. Browser fixed geometry is now the explicit setting terminal.fixed_geometry (default false), and an existing manual window without an OpenRig pin record is left untouched. Writable viewers kept from 0.7.8, 0.7.9 or the earlier 0.7.11 candidate are refreshed by closing only the owned viewer pane and reopening the view (a following view needs --no-follow first); the agent keeps running and nothing is migrated silently. Existing 0.7.10 sizing clients and read-only viewers are unchanged. As in 0.7.10, Enter or Ctrl-C typed while a new tile is still attaching can close that viewer. The writable script has no apostrophes or control characters, so Herdr keeps its restart command.
2026-11-09 18:36:51 -08:00
44 changed files with 803 additions and 542 deletions

No files matched your search

@@ -280,7 +280,13 @@ canceled, no-follow-on, escalation, superseded}`. The reasons
`closure_target` (`:86`):
- `handed_off_to` — work continues with a different seat (`closure_target` =
new owner).
new owner). `rig queue show` on such a row names the row the handoff made in
`successors` (rows whose `handedOffFrom` is this row, and a local row its
closure target names), or says in `successorNote` why none is named. A
cross-host successor is named as recorded (`<id>@<host>`); nothing stored
changes. Past 20 such rows the newest 20 are named, with
`successorsTruncated: true`. A scoped MCP caller doesn't get these fields;
`rig_queue_show`'s scope-checked `successor` answers it instead.
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
To wait for a qitem, use `rig queue block <id> --on <child>` or
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
+38 -2
View File
@@ -76,12 +76,48 @@ then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
Detaching a tile you typed in returns it to showing the seat. A different seat
under the old name is never followed. If no replacement returns in time, or a
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
the agent's PTY under tmux's normal client sizing policy, and resizing the
terminal reflows new output. Read-only tiles remain observers: they neither
resize nor type into another seat. An ordinary tmux detach ends the writable
tile without stopping the agent. A replacement with a different managed seat
identity is not followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
opened before this change must be closed and reopened to get this behavior.
As in published 0.7.10, Enter or Ctrl-C typed while a new writable tile is still
attaching can close that viewer. The agent keeps running; reopen the view.
Once attached, keys and paste use native tmux input handling.
## Sizing and older views
Use tmux's inherited `window-size latest` policy for the terminal most recently
used to control sizing. OpenRig does not change a person's global tmux policy.
A deliberate `window-size manual` setting remains an explicit fixed-size choice.
OpenRig's temporary layout and readable-capture pins release by unsetting the
window's local `window-size` option; they do not restore an old local override.
With the last pin released, the window inherits the global policy again.
Browser terminals do not pin the agent's PTY by default. For an explicit fixed
90-by-27 browser terminal, use `rig config set terminal.fixed_geometry true`.
Set it to `false` to return to the default. This applies to newly created browser
brokers after existing browser subscribers disconnect; it does not configure
native Herdr tiles. A temporary readable capture uses
`rig capture <seat> --readable` or `--size <columns>x<rows>` and releases its pin
when the capture finishes. It does not unset a manually fixed window it did not
pin.
Kept writable 0.7.8/0.7.9 viewers can retain an old `ignore-size` attach, and
pre-reflow 0.7.11 viewers can retain the old mirrored display. Changing saved
argv cannot change that running client. Close only the owned Herdr viewer pane
and open a fresh view with `rig terminal open <view> --provider herdr`; the
agent's tmux session and conversation keep running. For a retained `--follow`
canvas, first select `--no-follow`, then close its owned viewer panes and open
a fresh ordinary view. Continuing that old following canvas is not an automatic
live-client migration. Existing 0.7.10 sizing clients and read-only viewers do
not need this step.
## Desktop windows and previews
```sh
+6 -6
View File
@@ -54,9 +54,9 @@ export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
/** What happened to the window after a readable read, as far as OpenRig knows. */
export function readableRelease(release: string | undefined): string {
switch (release) {
case "given-back": return "its window was then given back its size and its own window-size";
case "given-back": return "its size was put back and its window-size override was unset";
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
case "failed": return "giving its window back failed; a later readable capture can retry releasing its pin";
default: return "its window's record had changed, so OpenRig left the window as it found it";
}
}
@@ -122,7 +122,7 @@ function renderCapture(data: Record<string, unknown>): void {
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
if (readable?.resized === true) {
const after = readable.release === "given-back" && readable.sizeBack
? `its own window-size was given back, but ${readable.sizeBack}`
? `its window-size override was unset, but ${readable.sizeBack}`
: readableRelease(readable.release);
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
@@ -190,11 +190,11 @@ Supported notes:
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
window, waits for the seat to redraw, captures, then puts the window back, both its size and
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
window, waits for the seat to redraw, captures, then puts its size back and unsets the temporary
window-size override, so Herdr, tmux attach and other clients size it again. The
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
web terminal has opted into fixed geometry), one whose sizing can't be read, and one with several panes. With --rig
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
are. A stderr note says which; --json has it as readable.
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
+1
View File
@@ -127,6 +127,7 @@ Keys:
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
terminal.fixed_geometry use 90x27 web geometry while subscribed (default off; other viewers share the pinned size)
terminal.status_bar show the inner tmux status bar on launch (default off)
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
+6
View File
@@ -170,6 +170,7 @@ export interface RiggedConfig {
tmuxServer: "inherit" | "isolated";
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
historyLimit: number;
fixedGeometry: boolean;
inheritTmuxEnvironment: boolean;
};
// Slice 15 (OPR.0.7.0.15) — the ONE switch for compact command-output
@@ -373,6 +374,7 @@ const DEFAULTS = {
statusBar: false,
tmuxServer: "inherit",
historyLimit: 10000,
fixedGeometry: false,
inheritTmuxEnvironment: false,
},
// Slice 15 — compact command-output defaults OFF until the switch is on.
@@ -539,6 +541,7 @@ export const VALID_KEYS = [
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 — compact command-output defaults; lockstep with the daemon twin.
"output.compact",
@@ -676,6 +679,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
@@ -785,6 +789,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
"output.whoami_peers": ["output", "whoamiPeers"],
@@ -1451,6 +1456,7 @@ export class ConfigStore {
statusBar: v("terminal.status_bar") as boolean,
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
historyLimit: v("terminal.history_limit") as number,
fixedGeometry: v("terminal.fixed_geometry") as boolean,
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
},
output: {
+15 -2
View File
@@ -305,6 +305,18 @@ function projectForExternal<T>(row: T, view: ExternalView): T {
return fields as T;
}
/** A handed-on item as a scoped caller receives it: without the daemon's own successor fields (successors,
* successorsTruncated, successorNote), which name rows and remote ids regardless of this caller's scope. Its answer is
* show's scope-checked successor (successorOf). */
function withoutDaemonSuccessors<T>(row: T): T {
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
const fields = { ...(row as Record<string, unknown>) };
delete fields.successors;
delete fields.successorsTruncated;
delete fields.successorNote;
return fields as T;
}
/** What an authenticated non-owner caller may reach: its own seat, its rigs' seats, the items they send or own (to read),
* the items they own (to change), and its shared roots. A person (<entityId>@external) is never in a non-owner's scope. */
function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
@@ -1433,9 +1445,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const view = externalView(scope);
let successor = handedOn ? await successorOf(client, qitemId, row.closureTarget, view ? scope!.item : undefined) : undefined;
if (scope && !view && successor && "destinationSession" in successor && !scope.successor(successor.destinationSession)) successor = { unknown: true, code: "out_of_scope" };
const shownItem = scope ? withoutDaemonSuccessors(item.data) : item.data;
const data = view
? { item: projectForExternal(item.data, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
: { item: item.data, transitions: transitions.data };
? { item: projectForExternal(shownItem, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
: { item: shownItem, transitions: transitions.data };
return mapResult({ status: 200, data: { ...data, ...(handedOn ? { successor } : {}) } }, undefined, { structured: true });
} catch (err) {
return caught(err);
+12 -10
View File
@@ -520,18 +520,20 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
pane = placeholder;
} else pane = (await runTmux([...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
// Nested source clients on this server can make a new window too narrow to split.
// Use the viewer's configured detached size, then restore normal client resizing.
// Use the viewer's detached size only while splitting; always release to inherited client sizing.
const [width, height] = (await runTmux(["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
const sizing = (await runTmux(["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
for (const item of page.slice(1)) {
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
try {
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
for (const item of page.slice(1)) {
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
}
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
} finally {
await runTmux(["set-option", "-wu", "-t", `${viewer}:${name}`, "window-size"]);
}
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await runTmux(["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
}
await runTmux(["select-window", "-t", `${viewer}:view-1`]);
if (!window) {
+2 -2
View File
@@ -185,7 +185,7 @@ describe("Capture CLI", () => {
const { out, err } = await run(["read@my-rig", "--readable"]);
expect(requests.at(-1)!.readable).toEqual({ width: 120, height: 40 });
expect(out).toEqual(["line1\nline2\n"]);
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its window was then given back its size and its own window-size]"]);
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its size was put back and its window-size override was unset]"]);
});
it("says so when a web terminal took the window over meanwhile, instead of claiming a give-back", async () => {
@@ -195,7 +195,7 @@ describe("Capture CLI", () => {
it("says when the size couldn't be put back, without claiming it was", async () => {
const { err } = await run(["sizeback@my-rig", "--readable"]);
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its own window-size was given back, but putting its size back failed: busy]"]);
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its window-size override was unset, but putting its size back failed: busy]"]);
});
it("a readable request waits out the daemon's readable budget; other captures keep the usual wait", async () => {
@@ -44,7 +44,7 @@ function clearEnv(): () => void {
// its custom home into default-path resolution; UNSETTING it would fall through
// to the REAL ~/.openrig (foreign on-box state) — so tests set a fresh tmp home.
"OPENRIG_HOME", "RIGGED_HOME",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_TERMINAL_FIXED_GEOMETRY", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
@@ -188,6 +188,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
"output.compact",
+11
View File
@@ -246,7 +246,18 @@ describe("rig mcp serve --http on the daemon's routes", () => {
const own = (await asCarol("rig_queue_create", { destination: "impl@team", body: "hand this on" })).structured.qitemId as string;
const handoff = await fetch(`${daemonUrl}/api/queue/${own}/handoff`, { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "impl@team" }, body: JSON.stringify({ toSession: "qa@other", nudge: false }) });
expect(handoff.status).toBe(201);
const next = ((await handoff.json()) as { created: { qitemId: string } }).created.qitemId;
expect((await asCarol("rig_queue_show", { qitemId: own })).structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
// Nor do the daemon's own successor fields, which it fills without a caller's scope (here: 21 successors, so the list
// is cut too).
const more = db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body, handed_off_from)
VALUES (?, '2026-10-26T00:00:00.000Z', '2026-10-26T00:00:00.000Z', 'impl@team', 'qa@other', 'pending', 'routine', 'x', ?)`);
for (let n = 0; n < 20; n++) more.run(`qitem-more-${n}`, own);
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ successorsTruncated: true, successors: expect.arrayContaining([expect.objectContaining({ qitemId: next })]) });
const handedOn = await asCarol("rig_queue_show", { qitemId: own });
for (const derived of ["successors", "successorsTruncated", "successorNote"]) expect(handedOn.structured.item).not.toHaveProperty(derived);
expect(handedOn.text).not.toContain(next);
expect(handedOn.text).not.toContain("qitem-more-");
// Artifacts: a root outside the grant is out of reach, directly or through an item.
fs.writeFileSync(path.join(shared, "report.md"), "ARTIFACT body");
@@ -963,6 +963,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(shown.structured.item).toMatchObject({ state: "handed-off", closureReason: "handed_off_to", handedOffTo: null, closureTarget: null,
closingTransition: { closureReason: "handed_off_to", closureTarget: null, transitionNote: "handed on (out of scope)" } });
expect(shown.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
for (const derived of ["successors", "successorsTruncated", "successorNote"]) expect(shown.structured.item).not.toHaveProperty(derived);
expect(JSON.stringify((await asExternal("rig_queue_list", { all: true })).structured)).not.toContain("impl@team-a");
// A cross-host successor's parties can't be checked here: its id and host stay hidden.
@@ -970,6 +971,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = 'qitem-far01@other-host', handed_off_to = 'impl@team-a@other-host' WHERE qitem_id = ?").run(remote);
const far = await asExternal("rig_queue_show", { qitemId: remote });
expect(far.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
expect(far.structured.item).not.toHaveProperty("successorNote");
expect(far.text).not.toContain("other-host");
// A successor addressed to the caller but sent by another caller-shaped seat isn't the team's; nor is a sibling the
@@ -149,6 +149,8 @@ describe("MCP queue tools on the daemon's queue routes", () => {
const shown = await desk("rig_queue_show", { qitemId });
expect(shown.data.item).toMatchObject({ state: "handed-off" });
// An unscoped caller gets the daemon's successor fields as they are.
expect(shown.data.item.successors).toEqual([{ qitemId: next, destinationSession: "qa@team", state: "pending", via: "handedOffFrom" }]);
expect(shown.structured).toMatchObject({ successor: { qitemId: next, destinationSession: "qa@team", state: "pending" } });
expect(shown.data.successor).toEqual((shown.structured as { successor: unknown }).successor);
const onward = await desk("rig_queue_list", { handedOffFrom: qitemId });
@@ -72,6 +72,7 @@ it.each([
expect(tmux(viewer, ["list-panes", "-s", "-t", `=openrig-view-${id}`, "-F", "#{pane_dead}"]).trim().split("\n")).toEqual(Array(19).fill("0"));
for (const page of [1, 2]) {
expect(tmux(viewer, ["show-options", "-Awv", "-t", `openrig-view-${id}:view-${page}`, "window-size"]).trim()).toBe("latest");
expect(tmux(viewer, ["show-options", "-wv", "-t", `openrig-view-${id}:view-${page}`, "window-size"]).trim()).toBe("");
}
}, 40_000);
+2
View File
@@ -6,6 +6,7 @@ import { queueErasureSchema } from "./migrations/155_queue_erasure.js";
import { queueErasureLinkIndexesSchema } from "./migrations/156_queue_erasure_link_indexes.js";
import { queueErasureBatchesSchema } from "./migrations/157_queue_erasure_batches.js";
import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.js";
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -319,4 +320,5 @@ export const ALL_MIGRATIONS: Migration[] = [
queueErasureLinkIndexesSchema,
queueErasureBatchesSchema,
queueInboxCheckinsSchema,
queueItemsHandedOffFromIndexSchema,
];
@@ -0,0 +1,10 @@
import type { Migration } from "../migrate.js";
/** A handed-off row's successor is found by its `handed_off_from` (`rig queue show`, the follow-through read). Without
* this, each lookup scans the whole queue. Partial: only successor rows carry the column. */
export const queueItemsHandedOffFromIndexSchema: Migration = {
name: "159_queue_items_handed_off_from_index.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_queue_items_handed_off_from ON queue_items(handed_off_from) WHERE handed_off_from IS NOT NULL;
`,
};
@@ -0,0 +1,149 @@
// Frozen r6 attachment generator for exact upgrade recognition and read-only observation.
// A tile owns its view and attach processes; no daemon watcher. Its view reads the seat only after new activity.
// Session names survive fresh launch, handover and restart. The node identity
// must survive too: never follow a same-name session belonging to another seat.
export function tileAttachArgv0711(
tmux: string, server: readonly string[], session: string, readOnly: boolean,
retrySeconds = 30, idleSeconds = 30,
): string[] {
// A tmux window has one size, and a client narrower than it sees only the left part of each line. So a tile shows the
// seat by mirroring it rather than attaching, and attaches only while the person types in it.
// - view: the seat's own screen, printed into the tile, whose terminal wraps each line at the tile's width. The tile is
// then no tmux client of the seat, so it never sizes, reflows or types into it. The screen is read again only when
// tmux records new activity in the window (read each second), and the view ends when the session goes, or, for a
// writable tile, when the person presses a key.
// - use (writable tiles): that first key goes to the seat and the tile attaches, sizing the seat while it's used, as
// any client the person types in does. Once the tile has had no key for idleSeconds it detaches back to the view,
// as any other detach of it does.
// - A read-only tile only views.
const open = readOnly
? 'open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };'
: 'open() { t=$1; shift; while use "$t" "$@"; do :; done; };';
// No apostrophes or control characters in the script: Herdr also stores this
// argv as its restart command. All external values stay positional arguments.
const script = [
// Files the script makes (the first input) are readable by this user alone.
'umask 077; b0=$(printf "\\033[200~"); b1=$(printf "\\033[201~");',
// A box-drawing rule (Claude Code draws them as wide as its window) is cut to the tile's width, so a narrow tile
// doesn't spend most of its rows on one wrapped rule. The cut works on bytes, so no character can blank the view.
// One tmux call a second reads the activity time and the screen together; the screen is drawn only after new
// activity, and once more after it, since tmux records activity in whole seconds. The tile's width is read again
// only after it's resized.
// A session id is unique only within one tmux server, and a restarted server numbers its sessions from $0 again.
// So each read also checks the server, and for a managed seat its identity, and anything else ends the view.
'view() { t=$1; keys=$2; shift 2; last=; again=; d=$(printf "\\342\\224\\200"); nl=$(printf "\\nx"); nl=${nl%x};',
'case "$t" in =*) p=;; *) p=${snapshot%% *};; esac; n=$node; cols=; rw=; rule=; trap "cols=" WINCH;',
'while :; do',
'if [ -n "$n" ]; then r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; show-environment -t "$t" OPENRIG_NODE_ID \\; capture-pane -p -e -t "$t" 2>/dev/null) || break;',
'else r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; capture-pane -p -e -t "$t" 2>/dev/null) || break; fi;',
'l=${r%%"$nl"*}; case "$r" in *"$nl"*) s=${r#*"$nl"};; *) s=;; esac; a=${l#* };',
'[ -n "$a" ] && { [ -z "$p" ] || [ "${l%% *}" = "$p" ]; } || break;',
'if [ -n "$n" ]; then [ "${s%%"$nl"*}" = "$n" ] || break; case "$s" in *"$nl"*) s=${s#*"$nl"};; *) s=;; esac; fi;',
'if [ "$a" != "$last" ] || [ -n "$again" ]; then',
'if [ "$a" != "$last" ]; then again=1; else again=; fi;',
'case "$s" in *"$d$d$d$d"*) if [ -z "$cols" ]; then cols=$(stty size 2>/dev/null) || cols=; cols=${cols#* }; fi;',
'case "$cols" in ""|*[!0-9]*) ;; *) if [ "$cols" -gt 0 ] && [ "$cols" -le 255 ]; then',
'if [ "$cols" != "$rw" ]; then rule=$(printf "%${cols}s" "" | LC_ALL=C sed "s/ /$d/g"); rw=$cols; fi;',
's=$(printf "%s\\n" "$s" | LC_ALL=C sed "s/\\($d\\)\\{$cols,\\}/$rule/g"); fi;; esac;; esac;',
'printf "\\033[H\\033[2J%s\\n" "$s"; last=$a; fi;',
// A writable tile waits for its next read on its own input, so the first key ends the wait at once. The input
// goes to a file, which keeps every byte. A wait that can't run ends the view rather than reading without pause.
'if [ -n "$keys" ]; then dd bs=4096 count=1 of="$f" 2>/dev/null || return 1; if [ -s "$f" ]; then return 0; fi;',
'else sleep 1 || return 1; fi;',
'done; return 1; };',
// The first input goes through tmux as an attached client's does, and only while it's still the same seat. A
// writable tile's terminal frames pastes while it shows, and until the tile attaches. The whole first burst is read
// into a file, through any paste it opens, with a UTF-8 character it ends inside completed. Nothing passes through
// a shell variable, so every byte is kept, NUL included, and flow-control and other special keys are read as keys.
// - Keys alone go in the attach command itself, through the new client (send-keys -K): tmux runs them for that
// client before anything typed since, so its key table applies, the prefix included. That needs a tmux 3.5 or later
// server (3.3a has no -K, and 3.4 drops keys sent that way). The server's own #{version} decides, read just before
// the attach: a newer tmux binary can still be talking to an older server. Below 3.5, or when the version can't be
// read, they go to the pane before the tile attaches: a prefix typed first then reaches the seat instead of acting
// as the prefix.
// - A burst with a paste goes to the pane in order before the tile attaches: keys, then the paste (paste-buffer
// frames it if the agent asked), then keys. tmux reads anything typed since only once the tile has attached.
// The tile's terminal keeps that one mode (raw, paste framing on) from the first key until tmux takes the terminal at
// attach, and tmux puts it back at detach; only a tile that doesn't attach restores it itself.
// Without a terminal to read keys from, or a file to keep them in, a writable tile only views.
'use() { t=$1; shift; f=$(mktemp 2>/dev/null) || f=;',
'if [ -n "$f" ] && stty -icanon -echo -isig -iexten -ixon -icrnl min 0 time 10 2>/dev/null; then',
'printf "\\033[?2004h"; view "$t" 1 "$@"; seen=$?; [ "$seen" -ne 0 ] || take;',
'else view "$t" "" "$@"; seen=$?; fi;',
'if [ "$seen" -ne 0 ] || ! here "$@"; then printf "\\033[?2004l"; stty sane 2>/dev/null; [ -z "$f" ] || rm -f "$f" "$f".*; return 1; fi;',
'pz=; if LC_ALL=C grep -aqF -- "$b0" "$f"; then pz=1; put "$@"; fi;',
'me=$(tty 2>/dev/null) || me=; w=; if [ -n "$me" ]; then idle "$me" "$@" & w=$!; fi;',
'kk=; case "$("$@" display-message -p -t "$t" "#{version}" 2>/dev/null)" in next-*|master|[4-9].*|[1-9][0-9].*|3.[5-9]*|3.[1-9][0-9]*) kk=1;; esac;',
'ng=1; if [ -z "$pz" ] && [ -s "$f" ]; then if [ -n "$kk" ]; then ng=; go "$@"; else kz 0 $(($(wc -c < "$f"))) "$@"; fi; fi; [ -z "$ng" ] || "$@" attach -t "$t";',
'if [ -n "$w" ]; then kill "$w" 2>/dev/null; wait "$w" 2>/dev/null; fi; rm -f "$f" "$f".*; return 0; };',
'take() { i=0; while [ "$i" -lt 256 ]; do',
'if [ $(($(LC_ALL=C grep -abo -F -- "$b0" "$f" | wc -l))) -gt $(($(LC_ALL=C grep -abo -F -- "$b1" "$f" | wc -l))) ];',
'then stty min 0 time 10 2>/dev/null; else stty min 0 time 1 2>/dev/null; i=$((i + 1)); fi;',
'dd bs=4096 count=1 of="$f.n" 2>/dev/null && [ -s "$f.n" ] || break; cat "$f.n" >> "$f"; done; rm -f "$f.n";',
'm=0; for c in $(tail -c 4 "$f" | od -An -tu1 -v); do',
'if [ "$c" -lt 128 ]; then m=0; elif [ "$c" -lt 192 ]; then [ "$m" -eq 0 ] || m=$((m - 1)); elif [ "$c" -lt 224 ]; then m=1;',
'elif [ "$c" -lt 240 ]; then m=2; elif [ "$c" -lt 248 ]; then m=3; else m=0; fi; done;',
'if [ "$m" -gt 0 ]; then stty min 0 time 10 2>/dev/null; dd bs=1 count="$m" 2>/dev/null >> "$f"; fi; };',
'here() { v=$("$@" display-message -p -t "$t" "#{pid} #{session_id}" 2>/dev/null) || return 1; [ -n "${v#* }" ] || return 1;',
'[ -z "$p" ] || [ "${v%% *}" = "$p" ] || return 1;',
'[ -z "$n" ] || [ "$("$@" show-environment -t "$t" OPENRIG_NODE_ID 2>/dev/null)" = "$n" ]; };',
'kn() { case "$1" in 0) k=C-Space;; 9) k=Tab;; 13) k=Enter;; 27) k=Escape;; 28) k=C-\\\\;; 29) k=C-];; 30) k=C-^;; 31) k=C-_;; 127) k=BSpace;;',
'*) k=C-$(printf "\\\\$(printf %o $(($1 + 96)))");; esac; };',
// Keys alone: in the attach command, through the new client.
'go() { set -- "$@" attach -t "$t"; x=0; r0=0; for c in $(od -An -tu1 -v "$f"); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi;',
'kn "$c"; set -- "$@" \\; send-keys -K "$k"; r0=$((x + 1)); fi; x=$((x + 1)); done;',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi; "$@"; };',
// tmux ends a command at an argument ending in ;, so a run of text keeps its last ; escaped.
'tx() { u=$(dd if="$f" bs=1 skip="$1" count=$(($2 - $1)) 2>/dev/null); case "$u" in *";") u="${u%?}\\\\;";; esac; };',
// A burst with a paste: each part to the pane, in order, before the tile attaches.
'put() { z=$(($(wc -c < "$f"))); pos=0; j=0; while [ "$pos" -lt "$z" ]; do',
'a=$(dd if="$f" bs=1 skip="$pos" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b0" | head -n 1); a=${a%%:*};',
'if [ -z "$a" ]; then kz "$pos" "$z" "$@"; break; fi; kz "$pos" $((pos + a)) "$@"; a=$((pos + a + 6));',
'e=$(dd if="$f" bs=1 skip="$a" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b1" | head -n 1); e=${e%%:*}; [ -n "$e" ] || e=$((z - a));',
'j=$((j + 1)); dd if="$f" of="$f.p$j" bs=1 skip="$a" count="$e" 2>/dev/null;',
'"$@" load-buffer -b "openrig-tile-$$-$j" "$f.p$j" \\; paste-buffer -p -d -b "openrig-tile-$$-$j" -t "$t" 2>/dev/null; pos=$((a + e + 6)); done; };',
'kz() { u=$1; y=$2; shift 2; [ "$y" -gt "$u" ] || return 0; x=$u; r0=$u;',
'for c in $(dd if="$f" bs=1 skip="$u" count=$((y - u)) 2>/dev/null | od -An -tu1 -v); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi;',
'kn "$c"; "$@" send-keys -t "$t" "$k" 2>/dev/null; r0=$((x + 1)); fi; x=$((x + 1)); done;',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi; };',
'idle() { me=$1; shift; while sleep 1; do',
'a=$("$@" display-message -p -c "$me" "#{client_activity}" 2>/dev/null) || return 0;',
`if [ $(($(date +%s) - a)) -ge ${idleSeconds} ]; then "$@" detach-client -t "$me" 2>/dev/null; return 0; fi;`,
'done; };',
open,
'target=$1; shift;',
// When the seat can't be read, any view ends as soon as it starts, and tmux's own error (or the shell's, when
// tmux isn't found) says why.
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { open "=$target" "$@"; exec "$@" has-session -t "=$target"; };`,
// display-message may succeed with empty/fallback context for a missing target.
`if [ "\${snapshot#*:}" != "$target" ]; then open "=$target" "$@"; exec "$@" has-session -t "=$target"; fi;`,
'snapshot=${snapshot%%:*};',
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
'if [ -z "$node" ]; then',
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
`open "\${snapshot#* }" "$@"; exit 0; fi;`,
'while :; do',
`open "\${snapshot#* }" "$@";`,
`left=${retrySeconds};`,
'while [ "$left" -gt 0 ]; do',
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
'if [ -n "$next" ]; then',
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
// A session can disappear between the two metadata reads; unknown waits.
'if [ -n "$identity" ]; then',
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
// A view that ended while its session is still there (a failed read) views it again.
'if [ "$next" = "$snapshot" ]; then sleep 1 || exit 1; fi; snapshot=$next; break;',
'fi; fi;',
'sleep 1; left=$((left - 1));',
'done;',
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
'done',
].join(" ");
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
}
@@ -180,25 +180,28 @@ export class HerdrFollowView {
// Validate ALL old identities before any change. One failed read retains
// the whole view, rather than presenting an untrusted partial inventory.
for (const tile of state.tiles) {
let expectedTarget = tile.target;
const currentMember = view.task.members.find(m => keyOf(m.key.hostId, m.key.nodeId) === tile.key);
if (currentMember?.availability === "available") {
const target = view.opened.find(p => p.seat === currentMember.seat);
// An upgrade can change only the tile script; the kept tile then follows the current target.
// Recognise an older generated script, but don't rewrite its saved target until the
// actual viewer is verified. An old live writable client may require an explicit reopen.
if (target?.attachArgv && tile.target.attachArgv && target.paneCommand !== tile.target.paneCommand
&& target.readOnly === tile.target.readOnly && earlierTileAttachment(tile.target.attachArgv, target.attachArgv, target.readOnly)) {
tile.target = target;
expectedTarget = target;
}
if (!target || target.paneCommand !== tile.target.paneCommand || target.readOnly !== tile.target.readOnly
|| JSON.stringify(target.attachArgv) !== JSON.stringify(tile.target.attachArgv)) {
if (!target || target.paneCommand !== expectedTarget.paneCommand || target.readOnly !== expectedTarget.readOnly
|| JSON.stringify(target.attachArgv) !== JSON.stringify(expectedTarget.attachArgv)) {
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
}
}
let current: ViewerPane;
try { current = await currentPane(tile.viewer); }
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
const verified = await viewers.lookup(tile.member, tile.target);
const verified = await viewers.lookup(tile.member, expectedTarget);
if (!verified.ok) throw new Error(`${tile.seat}: ${viewerFailure(tile.key, tile.target, verified)} Keeping the whole view.`);
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
tile.target = expectedTarget;
delete state.viewerFailures?.[tile.key];
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
}
@@ -7,6 +7,7 @@ import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import { resolveOnPath } from "../../adapters/tmux-exec.js";
import type { ComposedPane } from "./terminal-provider.js";
import { earlierTileAttachment } from "./tile-attach.js";
import { shellQuote } from "../../adapters/shell-quote.js";
export interface ViewerKey { hostId: string; nodeId: string }
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
@@ -176,13 +177,17 @@ export class HerdrViewer {
const id = JSON.stringify([key.hostId, key.nodeId]);
entry = journal.entries[id];
let created = false;
// An upgrade can change only the tile script (argv[2]); the seat it attaches is the same, so the kept
// viewer moves to the current script.
let reopen = false;
let earlier = false;
// Updating provenance alone cannot replace a live client. Keep old observers unchanged; a writable
// legacy viewer is reopened explicitly, preserving the source agent and avoiding unowned client changes.
if (entry && entry.command !== target.paneCommand && entry.readOnly === target.readOnly && Array.isArray(entry.argv)
&& entry.command === entry.argv.map(shellQuote).join(" ")
&& earlierTileAttachment(entry.argv, target.attachArgv, target.readOnly)) {
entry.command = target.paneCommand; entry.argv = target.attachArgv; if (provision) save();
earlier = true;
reopen = !entry.readOnly;
}
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
if (entry && !earlier && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
}
if (!entry) {
@@ -235,6 +240,7 @@ export class HerdrViewer {
delete journal.entries[id]; save();
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
}
if (reopen) return unconfirmed(`Close only the owned Herdr viewer pane ${current.pane_id}, then reopen this view with rig terminal open. The agent's tmux session is preserved; its old client cannot be migrated by changing saved argv alone.`);
if (entry.phase === "created") {
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
@@ -1,160 +1,24 @@
import { tileAttachArgv078, tileAttachArgv079, tileAttachArgv0710 } from "./earlier-tile-attach.js";
import { tileAttachArgv0711 } from "./earlier-tile-attach-0711.js";
// A tile owns its view and attach processes; no daemon watcher. Its view reads the seat only after new activity.
// Session names survive fresh launch, handover and restart. The node identity
// must survive too: never follow a same-name session belonging to another seat.
/** Writable tiles are native sizing clients: tmux's latest active client controls the PTY, including on resize.
* Once attached, tmux handles keys/paste directly; no capture mirror, replay buffer or idle detacher.
* Cross-rig read-only tiles retain their observer view and never type into or resize the source seat. */
export function tileAttachArgv(
tmux: string, server: readonly string[], session: string, readOnly: boolean,
retrySeconds = 30, idleSeconds = 30,
): string[] {
// A tmux window has one size, and a client narrower than it sees only the left part of each line. So a tile shows the
// seat by mirroring it rather than attaching, and attaches only while the person types in it.
// - view: the seat's own screen, printed into the tile, whose terminal wraps each line at the tile's width. The tile is
// then no tmux client of the seat, so it never sizes, reflows or types into it. The screen is read again only when
// tmux records new activity in the window (read each second), and the view ends when the session goes, or, for a
// writable tile, when the person presses a key.
// - use (writable tiles): that first key goes to the seat and the tile attaches, sizing the seat while it's used, as
// any client the person types in does. Once the tile has had no key for idleSeconds it detaches back to the view,
// as any other detach of it does.
// - A read-only tile only views.
const open = readOnly
? 'open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };'
: 'open() { t=$1; shift; while use "$t" "$@"; do :; done; };';
// No apostrophes or control characters in the script: Herdr also stores this
// argv as its restart command. All external values stay positional arguments.
const script = [
// Files the script makes (the first input) are readable by this user alone.
'umask 077; b0=$(printf "\\033[200~"); b1=$(printf "\\033[201~");',
// A box-drawing rule (Claude Code draws them as wide as its window) is cut to the tile's width, so a narrow tile
// doesn't spend most of its rows on one wrapped rule. The cut works on bytes, so no character can blank the view.
// One tmux call a second reads the activity time and the screen together; the screen is drawn only after new
// activity, and once more after it, since tmux records activity in whole seconds. The tile's width is read again
// only after it's resized.
// A session id is unique only within one tmux server, and a restarted server numbers its sessions from $0 again.
// So each read also checks the server, and for a managed seat its identity, and anything else ends the view.
'view() { t=$1; keys=$2; shift 2; last=; again=; d=$(printf "\\342\\224\\200"); nl=$(printf "\\nx"); nl=${nl%x};',
'case "$t" in =*) p=;; *) p=${snapshot%% *};; esac; n=$node; cols=; rw=; rule=; trap "cols=" WINCH;',
'while :; do',
'if [ -n "$n" ]; then r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; show-environment -t "$t" OPENRIG_NODE_ID \\; capture-pane -p -e -t "$t" 2>/dev/null) || break;',
'else r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; capture-pane -p -e -t "$t" 2>/dev/null) || break; fi;',
'l=${r%%"$nl"*}; case "$r" in *"$nl"*) s=${r#*"$nl"};; *) s=;; esac; a=${l#* };',
'[ -n "$a" ] && { [ -z "$p" ] || [ "${l%% *}" = "$p" ]; } || break;',
'if [ -n "$n" ]; then [ "${s%%"$nl"*}" = "$n" ] || break; case "$s" in *"$nl"*) s=${s#*"$nl"};; *) s=;; esac; fi;',
'if [ "$a" != "$last" ] || [ -n "$again" ]; then',
'if [ "$a" != "$last" ]; then again=1; else again=; fi;',
'case "$s" in *"$d$d$d$d"*) if [ -z "$cols" ]; then cols=$(stty size 2>/dev/null) || cols=; cols=${cols#* }; fi;',
'case "$cols" in ""|*[!0-9]*) ;; *) if [ "$cols" -gt 0 ] && [ "$cols" -le 255 ]; then',
'if [ "$cols" != "$rw" ]; then rule=$(printf "%${cols}s" "" | LC_ALL=C sed "s/ /$d/g"); rw=$cols; fi;',
's=$(printf "%s\\n" "$s" | LC_ALL=C sed "s/\\($d\\)\\{$cols,\\}/$rule/g"); fi;; esac;; esac;',
'printf "\\033[H\\033[2J%s\\n" "$s"; last=$a; fi;',
// A writable tile waits for its next read on its own input, so the first key ends the wait at once. The input
// goes to a file, which keeps every byte. A wait that can't run ends the view rather than reading without pause.
'if [ -n "$keys" ]; then dd bs=4096 count=1 of="$f" 2>/dev/null || return 1; if [ -s "$f" ]; then return 0; fi;',
'else sleep 1 || return 1; fi;',
'done; return 1; };',
// The first input goes through tmux as an attached client's does, and only while it's still the same seat. A
// writable tile's terminal frames pastes while it shows, and until the tile attaches. The whole first burst is read
// into a file, through any paste it opens, with a UTF-8 character it ends inside completed. Nothing passes through
// a shell variable, so every byte is kept, NUL included, and flow-control and other special keys are read as keys.
// - Keys alone go in the attach command itself, through the new client (send-keys -K): tmux runs them for that
// client before anything typed since, so its key table applies, the prefix included. That needs a tmux 3.5 or later
// server (3.3a has no -K, and 3.4 drops keys sent that way). The server's own #{version} decides, read just before
// the attach: a newer tmux binary can still be talking to an older server. Below 3.5, or when the version can't be
// read, they go to the pane before the tile attaches: a prefix typed first then reaches the seat instead of acting
// as the prefix.
// - A burst with a paste goes to the pane in order before the tile attaches: keys, then the paste (paste-buffer
// frames it if the agent asked), then keys. tmux reads anything typed since only once the tile has attached.
// The tile's terminal keeps that one mode (raw, paste framing on) from the first key until tmux takes the terminal at
// attach, and tmux puts it back at detach; only a tile that doesn't attach restores it itself.
// Without a terminal to read keys from, or a file to keep them in, a writable tile only views.
'use() { t=$1; shift; f=$(mktemp 2>/dev/null) || f=;',
'if [ -n "$f" ] && stty -icanon -echo -isig -iexten -ixon -icrnl min 0 time 10 2>/dev/null; then',
'printf "\\033[?2004h"; view "$t" 1 "$@"; seen=$?; [ "$seen" -ne 0 ] || take;',
'else view "$t" "" "$@"; seen=$?; fi;',
'if [ "$seen" -ne 0 ] || ! here "$@"; then printf "\\033[?2004l"; stty sane 2>/dev/null; [ -z "$f" ] || rm -f "$f" "$f".*; return 1; fi;',
'pz=; if LC_ALL=C grep -aqF -- "$b0" "$f"; then pz=1; put "$@"; fi;',
'me=$(tty 2>/dev/null) || me=; w=; if [ -n "$me" ]; then idle "$me" "$@" & w=$!; fi;',
'kk=; case "$("$@" display-message -p -t "$t" "#{version}" 2>/dev/null)" in next-*|master|[4-9].*|[1-9][0-9].*|3.[5-9]*|3.[1-9][0-9]*) kk=1;; esac;',
'ng=1; if [ -z "$pz" ] && [ -s "$f" ]; then if [ -n "$kk" ]; then ng=; go "$@"; else kz 0 $(($(wc -c < "$f"))) "$@"; fi; fi; [ -z "$ng" ] || "$@" attach -t "$t";',
'if [ -n "$w" ]; then kill "$w" 2>/dev/null; wait "$w" 2>/dev/null; fi; rm -f "$f" "$f".*; return 0; };',
'take() { i=0; while [ "$i" -lt 256 ]; do',
'if [ $(($(LC_ALL=C grep -abo -F -- "$b0" "$f" | wc -l))) -gt $(($(LC_ALL=C grep -abo -F -- "$b1" "$f" | wc -l))) ];',
'then stty min 0 time 10 2>/dev/null; else stty min 0 time 1 2>/dev/null; i=$((i + 1)); fi;',
'dd bs=4096 count=1 of="$f.n" 2>/dev/null && [ -s "$f.n" ] || break; cat "$f.n" >> "$f"; done; rm -f "$f.n";',
'm=0; for c in $(tail -c 4 "$f" | od -An -tu1 -v); do',
'if [ "$c" -lt 128 ]; then m=0; elif [ "$c" -lt 192 ]; then [ "$m" -eq 0 ] || m=$((m - 1)); elif [ "$c" -lt 224 ]; then m=1;',
'elif [ "$c" -lt 240 ]; then m=2; elif [ "$c" -lt 248 ]; then m=3; else m=0; fi; done;',
'if [ "$m" -gt 0 ]; then stty min 0 time 10 2>/dev/null; dd bs=1 count="$m" 2>/dev/null >> "$f"; fi; };',
'here() { v=$("$@" display-message -p -t "$t" "#{pid} #{session_id}" 2>/dev/null) || return 1; [ -n "${v#* }" ] || return 1;',
'[ -z "$p" ] || [ "${v%% *}" = "$p" ] || return 1;',
'[ -z "$n" ] || [ "$("$@" show-environment -t "$t" OPENRIG_NODE_ID 2>/dev/null)" = "$n" ]; };',
'kn() { case "$1" in 0) k=C-Space;; 9) k=Tab;; 13) k=Enter;; 27) k=Escape;; 28) k=C-\\\\;; 29) k=C-];; 30) k=C-^;; 31) k=C-_;; 127) k=BSpace;;',
'*) k=C-$(printf "\\\\$(printf %o $(($1 + 96)))");; esac; };',
// Keys alone: in the attach command, through the new client.
'go() { set -- "$@" attach -t "$t"; x=0; r0=0; for c in $(od -An -tu1 -v "$f"); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi;',
'kn "$c"; set -- "$@" \\; send-keys -K "$k"; r0=$((x + 1)); fi; x=$((x + 1)); done;',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi; "$@"; };',
// tmux ends a command at an argument ending in ;, so a run of text keeps its last ; escaped.
'tx() { u=$(dd if="$f" bs=1 skip="$1" count=$(($2 - $1)) 2>/dev/null); case "$u" in *";") u="${u%?}\\\\;";; esac; };',
// A burst with a paste: each part to the pane, in order, before the tile attaches.
'put() { z=$(($(wc -c < "$f"))); pos=0; j=0; while [ "$pos" -lt "$z" ]; do',
'a=$(dd if="$f" bs=1 skip="$pos" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b0" | head -n 1); a=${a%%:*};',
'if [ -z "$a" ]; then kz "$pos" "$z" "$@"; break; fi; kz "$pos" $((pos + a)) "$@"; a=$((pos + a + 6));',
'e=$(dd if="$f" bs=1 skip="$a" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b1" | head -n 1); e=${e%%:*}; [ -n "$e" ] || e=$((z - a));',
'j=$((j + 1)); dd if="$f" of="$f.p$j" bs=1 skip="$a" count="$e" 2>/dev/null;',
'"$@" load-buffer -b "openrig-tile-$$-$j" "$f.p$j" \\; paste-buffer -p -d -b "openrig-tile-$$-$j" -t "$t" 2>/dev/null; pos=$((a + e + 6)); done; };',
'kz() { u=$1; y=$2; shift 2; [ "$y" -gt "$u" ] || return 0; x=$u; r0=$u;',
'for c in $(dd if="$f" bs=1 skip="$u" count=$((y - u)) 2>/dev/null | od -An -tu1 -v); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi;',
'kn "$c"; "$@" send-keys -t "$t" "$k" 2>/dev/null; r0=$((x + 1)); fi; x=$((x + 1)); done;',
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi; };',
'idle() { me=$1; shift; while sleep 1; do',
'a=$("$@" display-message -p -c "$me" "#{client_activity}" 2>/dev/null) || return 0;',
`if [ $(($(date +%s) - a)) -ge ${idleSeconds} ]; then "$@" detach-client -t "$me" 2>/dev/null; return 0; fi;`,
'done; };',
open,
'target=$1; shift;',
// When the seat can't be read, any view ends as soon as it starts, and tmux's own error (or the shell's, when
// tmux isn't found) says why.
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { open "=$target" "$@"; exec "$@" has-session -t "=$target"; };`,
// display-message may succeed with empty/fallback context for a missing target.
`if [ "\${snapshot#*:}" != "$target" ]; then open "=$target" "$@"; exec "$@" has-session -t "=$target"; fi;`,
'snapshot=${snapshot%%:*};',
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
'if [ -z "$node" ]; then',
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
`open "\${snapshot#* }" "$@"; exit 0; fi;`,
'while :; do',
`open "\${snapshot#* }" "$@";`,
`left=${retrySeconds};`,
'while [ "$left" -gt 0 ]; do',
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
'if [ -n "$next" ]; then',
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
// A session can disappear between the two metadata reads; unknown waits.
'if [ -n "$identity" ]; then',
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
// A view that ended while its session is still there (a failed read) views it again.
'if [ "$next" = "$snapshot" ]; then sleep 1 || exit 1; fi; snapshot=$next; break;',
'fi; fi;',
'sleep 1; left=$((left - 1));',
'done;',
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
'done',
].join(" ");
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
return readOnly ? tileAttachArgv0711(tmux, server, session, true, retrySeconds, idleSeconds)
: tileAttachArgv0710(tmux, server, session, false, retrySeconds);
}
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command 0.7.10's, 0.7.9's,
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command pre-reflow 0.7.11's, 0.7.10's, 0.7.9's,
* or 0.7.5-0.7.8's, script makes for the current tile's session, tmux, server and read-only intent. An upgrade changes
* only OpenRig's own script; any other difference, an edited script included, is a different attachment. */
export function earlierTileAttachment(kept: readonly string[], current: readonly string[], readOnly: boolean): boolean {
if (current.length < 6) return false;
const [session, tmux, ...server] = current.slice(4) as [string, string, ...string[]];
return [tileAttachArgv0710, tileAttachArgv079, tileAttachArgv078].some((make) => {
return [tileAttachArgv0711, tileAttachArgv0710, tileAttachArgv079, tileAttachArgv078].some((make) => {
const earlier = make(tmux, server, session, readOnly);
return earlier.length === kept.length && earlier.every((word, i) => word === kept[i]);
});
@@ -193,6 +193,7 @@ export const SETTINGS_VALID_KEYS = [
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit; written by `rig setup` (CLI), twin of the CLI key.
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults; CLI-read, twin of the CLI key.
"output.compact",
@@ -340,6 +341,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
@@ -448,6 +450,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
"output.whoami_peers": ["output", "whoamiPeers"],
@@ -766,6 +769,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "terminal.status_bar": return false;
case "terminal.tmux_server": return "inherit";
case "terminal.history_limit": return 10000;
case "terminal.fixed_geometry": return false;
case "terminal.inherit_tmux_environment": return false;
case "output.compact": return false;
case "output.whoami_peers": return 8;
+35
View File
@@ -81,6 +81,39 @@ export function isAttentionItem(q: { tier: string | null; destinationSession: st
}
/** A row a handoff of this one made: the rows naming it as `handedOffFrom`, and a local row its closure target names. */
export interface HandoffSuccessor { qitemId: string; destinationSession: string; state: QueueState; via: "handedOffFrom" | "closureTarget" }
/** #2794: a handed-off row records its new owner as the closure target, not the row the handoff made. This names that row
* from what's already stored: the successor's own `handedOffFrom`, or a local row id in the closure target. A cross-host
* successor is named as recorded (`<id>@<host>`), never looked up remotely. A lookup that fails says so; it never fails
* the read. Past HANDOFF_SUCCESSOR_LIMIT rows naming it, the newest are named and `successorsTruncated` says so. */
export const HANDOFF_SUCCESSOR_LIMIT = 20;
export function handoffSuccessors(repo: QueueRepository, item: QueueItem): { successors: HandoffSuccessor[]; successorsTruncated?: true; successorNote?: string } {
try {
// Newest first, one past the bound so a cut list is known.
const found = repo.list({ handedOffFrom: item.qitemId, compact: true, limit: HANDOFF_SUCCESSOR_LIMIT + 1 });
const truncated = found.length > HANDOFF_SUCCESSOR_LIMIT;
const successors: HandoffSuccessor[] = found.slice(0, HANDOFF_SUCCESSOR_LIMIT)
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated) || a.qitemId.localeCompare(b.qitemId))
.map((row) => ({ qitemId: row.qitemId, destinationSession: row.destinationSession, state: row.state, via: "handedOffFrom" }));
const target = item.closureTarget;
if (target?.startsWith("qitem-") && !successors.some((s) => s.qitemId === target)) {
const named = repo.getById(target);
if (named) successors.push({ qitemId: named.qitemId, destinationSession: named.destinationSession, state: named.state, via: "closureTarget" });
}
if (successors.length > 0) return { successors, ...(truncated ? { successorsTruncated: true as const } : {}) };
return {
successors,
successorNote: target && /^qitem-[^@\s]+@[^@\s]+$/.test(target)
? `It was handed to another host; its successor there is ${target}. This daemon has no copy.`
: "No row on this daemon was made by this handoff.",
};
} catch {
return { successors: [], successorNote: "Its successor couldn't be looked up; the closure target is as recorded." };
}
}
export function queueRoutes(): Hono {
const app = new Hono();
@@ -1478,6 +1511,8 @@ export function queueRoutes(): Hono {
actorSession: closing.actorSession,
transitionNote: closing.transitionNote,
} } : {}),
// #2794: a handed-off row names the row its handoff made (read only; nothing stored changes).
...(item.state === "handed-off" || item.closureReason === "handed_off_to" ? handoffSuccessors(repo, item) : {}),
destinationClass: repo.classifyDestinationOf(item.destinationSession).class,
ownerNotificationLevel: repo.transitionLog.latestOwnerNotificationForQitem(qitemId)?.ownerNotificationLevel ?? null,
});
+3 -3
View File
@@ -37,12 +37,12 @@ export function registerTerminalAuthOnly(app: Hono, opts: { bearerToken: string
export function registerTerminalWs(
app: Hono,
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
opts: { bearerToken: string | null },
opts: { bearerToken: string | null; fixedGeometry?: () => boolean },
): void;
export function registerTerminalWs(
app: Hono,
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
opts: { bearerToken: string | null; livenessIntervalMs?: number },
opts: { bearerToken: string | null; livenessIntervalMs?: number; fixedGeometry?: () => boolean },
): void {
const terminalAuth = terminalAuthMiddleware(opts);
@@ -53,7 +53,7 @@ export function registerTerminalWs(
let registry: TerminalBrokerRegistry | null = null;
const getRegistry = (tmux: BrokerTmux): TerminalBrokerRegistry => {
if (!registry) {
registry = new TerminalBrokerRegistry(tmux, { livenessMs: opts.livenessIntervalMs });
registry = new TerminalBrokerRegistry(tmux, { livenessMs: opts.livenessIntervalMs, fixedGeometry: opts.fixedGeometry });
}
return registry;
};
+2 -1
View File
@@ -827,7 +827,8 @@ export function createApp(deps: AppDeps): Hono {
const ws = createNodeWebSocket({ app });
injectWebSocket = ws.injectWebSocket as never;
_lastInjectWebSocket = injectWebSocket;
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null });
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null,
fixedGeometry: () => deps.settingsStore?.resolveOne("terminal.fixed_geometry").value === true });
} else if (deps.enableNodeWebSocket) {
registerTerminalAuthOnly(app, { bearerToken: deps.terminalBearerToken ?? null });
}
@@ -15,8 +15,8 @@ import { newPinHolder, readPinSizing, recordPin, releasePin, WINDOW_SIZE_PRIOR_O
// for the same session (the per-connection bug): a second viewer of one seat
// fought the first for output. The broker fixes that: ONE tmux pipe per
// session, MANY subscribers, output fanned out to all, each subscriber seeded
// with a cursor-safe snapshot of the current screen on attach, fixed geometry
// (no client-driven resize), honest session-death reporting to ALL subscribers,
// with a cursor-safe snapshot of the current screen on attach, optional fixed geometry,
// honest session-death reporting to ALL subscribers,
// and full cleanup when the last subscriber leaves.
const PIPE_PANE_POLL_MS = 50;
@@ -33,15 +33,9 @@ const DEFAULT_LIVENESS_MS = 2000;
const MAX_HISTORY_BYTES = 64 * 1024;
/**
* Canonical fixed terminal geometry (FR-7). 90 cols (OPR.0.4.0.39, founder-directed):
* Claude Code (Ink) + Codex CLI are RESPONSIVE TUIs that reflow to whatever width they
* are given - they have no required width; 80 is the legacy fallback that users find
* too narrow, so 90 sits comfortably above the 80 floor while being narrower than 120 so
* the scaled static/live mirror reads bigger (more legible) in the topology grid cells.
* 27 rows gives the classic-terminal 1.72:1 landscape shape (90x27 = ~650x378px, matching the canonical 80x24 aspect) (founder: classic terminal rectangle) while staying a workable agent-TUI height; subscribers fit/scroll/pan their
* viewport but never resize the pane - so multiple viewers cannot shrink the session to
* the smallest one. MUST stay in sync with the client mirror LIVE_TERMINAL_COLS
* (packages/ui/.../terminal/terminal-geometry.ts) - the xterm grid must match the pane.
* Browser rendering grid and explicit fixed-geometry opt-in size. The default broker does not resize the
* agent PTY; native clients control it. Keep these browser grid values in sync with LIVE_TERMINAL_COLS/ROWS
* in packages/ui/src/terminal/terminal-geometry.ts. This does not make the browser a native sizing client.
*/
export const CANONICAL_COLS = 90;
export const CANONICAL_ROWS = 27;
@@ -79,6 +73,8 @@ export interface BrokerTmux {
}
export interface BrokerOptions {
/** Explicit web fixed-geometry opt-in. Default false; native clients otherwise own the PTY size. */
fixedGeometry?: boolean | (() => boolean);
/** File-tail poll interval (ms). Default 50. */
pollMs?: number;
/** Session-liveness probe interval (ms). Default 2000. */
@@ -199,6 +195,7 @@ export class TerminalSessionBroker {
// The window this broker pinned, by its immutable id, and listed itself as a holder of. When the last live holder lets
// go, the window gets back its own `window-size`, so native clients (Herdr, attach) size it again. Null: nothing held.
private pinnedWindow: string | null = null;
private readonly fixedGeometry: boolean;
private readonly holder = newPinHolder();
constructor(sessionName: string, tmux: BrokerTmux, opts: BrokerOptions = {}) {
@@ -208,6 +205,7 @@ export class TerminalSessionBroker {
this.livenessMs = opts.livenessMs ?? DEFAULT_LIVENESS_MS;
this.cols = opts.cols ?? CANONICAL_COLS;
this.rows = opts.rows ?? CANONICAL_ROWS;
this.fixedGeometry = typeof opts.fixedGeometry === "function" ? opts.fixedGeometry() : opts.fixedGeometry === true;
this.maxHistoryBytes = opts.maxHistoryBytes ?? MAX_HISTORY_BYTES;
this.onEmpty = opts.onEmpty;
}
@@ -420,19 +418,14 @@ export class TerminalSessionBroker {
const alive = await this.tmux.hasSession(this.sessionName);
if (!alive) return { ok: false, code: 1008, reason: `session not found: ${this.sessionName}` };
// FR-7 fixed geometry: window-size manual so tmux will NOT auto-shrink the
// window to the smallest attached client; then the canonical width/height
// ONCE. Deliberately NOT aggressive-resize, which does the opposite. The
// window's own value is read first and recorded on the window with this
// broker as a holder, and given back when the last holder lets go. The window
// is named by its id, so the pin and the give-back reach the same window even
// after a client switches windows, and the read, record and pin run in its
// queue (window-pin.ts), so a readable capture in this daemon takes its turn.
// Without a window id or a readable value, the session's window is pinned as
// before with nothing to give back.
const window = (await this.tmux.readWindow?.(this.sessionName, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.id ?? null;
const pinned = await (window ? withWindowPin(window, () => this.pinWindow(window)) : this.pinWindow(null));
if (!pinned || this.torndown) return { ok: false, code: 1011, reason: "terminal broker unavailable" }; // dispose gives it back
// Fixed web geometry is explicit opt-in. Record each holder on the immutable window id;
// the last release unsets the temporary override so native clients size it again.
// Unknown sizing is observed without making an untracked manual pin.
if (this.fixedGeometry) {
const window = (await this.tmux.readWindow?.(this.sessionName, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.id ?? null;
const pinned = await (window ? withWindowPin(window, () => this.pinWindow(window)) : this.pinWindow(null));
if (!pinned || this.torndown) return { ok: false, code: 1011, reason: "terminal broker unavailable" };
}
const outputPath = path.join(
os.tmpdir(),
@@ -461,11 +454,12 @@ export class TerminalSessionBroker {
private async pinWindow(window: string | null): Promise<boolean> {
const sizing = window ? await readPinSizing(this.tmux, window) : null;
if (this.torndown) return false; // disposed: pin nothing
if (sizing) {
this.pinnedWindow = sizing.window;
await recordPin(this.tmux, sizing, this.holder);
}
const target = sizing?.window ?? this.sessionName;
if (!sizing) return true; // Unknown geometry: observe normally rather than leave an untracked manual pin.
// A person's fixed window is not an OpenRig pin to take over or unset.
if (sizing.live === "manual" && !(await this.tmux.readWindow?.(sizing.window, WINDOW_SIZE_PRIOR_OPTION).catch(() => null))?.userOption.trim()) return true;
this.pinnedWindow = sizing.window;
if (!(await recordPin(this.tmux, sizing, this.holder))) { this.pinnedWindow = null; return true; }
const target = sizing.window;
await this.tmux.setWindowOption(target, "window-size", "manual").catch(() => {});
await this.tmux.resizeWindow(target, this.cols, this.rows).catch(() => {});
return true;
+4 -4
View File
@@ -18,7 +18,7 @@ export interface PinTmux {
restoreWindowOption?(target: string, option: string, value: string | null): Promise<TmuxResult>;
}
/** A window, its own `window-size` now (`live`), the value to give back (`prior`), and the live holders of a pin. */
/** A window, its own `window-size` now (`live`), the recorded pre-pin value (`prior`, retained for old holder compatibility), and the live holders of a pin. */
export interface PinSizing {
window: string;
live: string | null;
@@ -93,12 +93,12 @@ export async function recordPin(tmux: PinTmux, sizing: PinSizing, holder: string
return written?.ok === true;
}
/** What letting go did: the window got its prior back; another live holder keeps the pin; the window no longer lists
/** What letting go did: the window resumed inherited sizing; another live holder keeps the pin; the window no longer lists
* this holder; or the give-back failed (the record is kept for the next holder). */
export type PinRelease = "given-back" | "kept-by-another-holder" | "not-listed" | "failed";
/** Let go of `holder`'s pin on `window`, once. While another live holder remains, only drop this one. As the last one,
* run `beforeGiveBack` (a readable capture puts the size back), then give back the prior and drop the record. A window
* run `beforeGiveBack` (a readable capture puts the size back), then unset the temporary window-size override and drop the record. A window
* that no longer lists this holder (the record was cleared, or the tmux server restarted and `@N` is another window)
* is left alone. */
export function releasePin(tmux: PinTmux, window: string, holder: string, beforeGiveBack?: () => Promise<void>): Promise<PinRelease> {
@@ -112,7 +112,7 @@ export function releasePin(tmux: PinTmux, window: string, holder: string, before
return "kept-by-another-holder";
}
await beforeGiveBack?.().catch(() => {});
const restored = await tmux.restoreWindowOption?.(window, "window-size", record.prior).catch(() => null);
const restored = await tmux.restoreWindowOption?.(window, "window-size", null).catch(() => null);
// Kept when the give-back failed: this holder is then no longer live, so the next one gives it back instead.
if (!restored?.ok) return "failed";
await tmux.restoreWindowOption?.(window, WINDOW_SIZE_PRIOR_OPTION, null).catch(() => {});
@@ -74,8 +74,8 @@ describe("rig capture says when a pane is too small to show the prompt the seat
});
});
// `rig capture --readable`: read a small seat at a readable size, then give its window back exactly: its size, and its
// own window-size (resize-window sets that to manual, which would stop Herdr or attach sizing the window).
// `rig capture --readable`: read at a readable size, put its size back, then unset the temporary local window-size
// override so native clients size it under the inherited policy.
describe("rig capture --readable reads a small seat, then gives its window back", () => {
const dbs: Array<ReturnType<typeof createDb>> = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
@@ -146,16 +146,16 @@ describe("rig capture --readable reads a small seat, then gives its window back"
it.each([
["inherited sizing is unset again", null],
["the window's own sizing is set again", "largest"],
])("reads at 120x40, then puts back its size and its sizing: %s", async (_, own) => {
])("reads at 120x40, then puts back its size and resumes inherited sizing: %s", async (_, own) => {
const { tmux, calls, window } = fakeTmux({ own });
const result = await (await transportOver(tmux)).capture("review@rig", { readable: { width: 120, height: 40 } });
expect(result).toMatchObject({ ok: true, content: "the whole prompt\n", readable: { resized: true, width: 120, height: 40, release: "given-back" } });
expect(calls).toEqual([
`set @2 @openrig-window-size-prior=${own ?? "inherit"} H`,
"resize @2 120x40", "capture", "resize @2 18x12",
`restore @2 window-size=${own}`, "restore @2 @openrig-window-size-prior=null",
"restore @2 window-size=null", "restore @2 @openrig-window-size-prior=null",
]);
expect(window).toEqual({ own, recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
});
it("a web terminal that opens during the read keeps its pin: the read leaves the size to it and drops only itself", async () => {
@@ -183,7 +183,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
await atPanes; // the read has the window's sizing, and hasn't recorded its pin yet
const ok = async () => ({ ok: true as const });
const broker = new TerminalSessionBroker("review@rig", { ...tmux, hasSession: async () => true, startPipePane: ok, stopPipePane: ok,
sendKeys: ok, sendText: ok, getPaneCursorPosition: async () => null } as never, { pollMs: 10, livenessMs: 60_000 });
sendKeys: ok, sendText: ok, getPaneCursorPosition: async () => null } as never, { fixedGeometry: true, pollMs: 10, livenessMs: 60_000 });
const browser = { send: () => {}, close: () => {} };
const attaching = broker.attach(browser);
await new Promise((r) => setTimeout(r, 20));
@@ -244,7 +244,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
])("a pin whose holders are gone (a crash) is taken over and given back: %s", async (_, panes) => {
const { tmux, window } = fakeTmux({ own: "manual", recorded: `largest ${deadPid}.1`, panes });
const result = await (await transportOver(tmux)).capture("review@rig", { readable: { width: 120, height: 40 } });
expect(window).toEqual({ own: "largest", recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
expect(result.readable).toEqual(panes[0]!.width < 120
? { resized: true, width: 120, height: 40, release: "given-back" }
: { resized: false, reason: "the pane is already 130x45; a pin left on its window was given back" });
@@ -337,7 +337,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
await tmux(["set-option", "-w", "-t", "=review@rig:", "window-size", "largest"]);
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable).toMatchObject({ resized: true });
expect(await own("=review@rig:")).toBe("largest");
expect(await own("=review@rig:")).toBe("");
await tmux(["set-option", "-w", "-t", "=review@rig:", "window-size", "manual"]);
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable).toMatchObject({ resized: false });
@@ -353,7 +353,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
class BrowserOpensDuringRead extends TmuxAdapter {
override async capturePaneContent(target: string, lines: number) {
if (!broker) {
broker = new TerminalSessionBroker("review@rig", adapter, { pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
broker = new TerminalSessionBroker("review@rig", adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
await broker.attach(browser);
}
return super.capturePaneContent(target, lines);
@@ -376,7 +376,7 @@ describe("rig capture --readable reads a small seat, then gives its window back"
expect(await own("=review@rig:")).toBe("manual");
expect((await transport.capture("review@rig", { readable: { width: 120, height: 40 } })).readable)
.toEqual({ resized: false, reason: "the pane is already 120x40; a pin left on its window was given back" });
expect(await own("=review@rig:")).toBe("largest");
expect(await own("=review@rig:")).toBe("");
expect(await tmux(["show-options", "-q", "-w", "-v", "-t", "=review@rig:", "@openrig-window-size-prior"])).toBe("");
} finally {
await tmux(["kill-server"]).catch(() => {});
+2 -1
View File
@@ -3,6 +3,7 @@ import { eventsRetentionMarksSchema } from "../../src/db/migrations/153_events_r
import { queueErasureSchema } from "../../src/db/migrations/155_queue_erasure.js";
import { queueErasureBatchesSchema } from "../../src/db/migrations/157_queue_erasure_batches.js";
import { queueInboxCheckinsSchema } from "../../src/db/migrations/158_queue_inbox_checkins.js";
import { queueItemsHandedOffFromIndexSchema } from "../../src/db/migrations/159_queue_items_handed_off_from_index.js";
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
@@ -165,7 +166,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -0,0 +1,105 @@
# Shipped tile argv in real PTYs on an isolated tmux server; no desktop or agent process.
import fcntl, json, os, pty, shutil, signal, struct, subprocess, sys, tempfile, termios, time
argv = json.loads(sys.argv[1])
root = tempfile.mkdtemp(prefix="tile-reflow-")
sock = os.path.join(root, "server")
env = dict(os.environ, TERM="xterm-256color")
env.pop("TMUX", None)
clients = []
def call(*args):
return subprocess.check_output(["tmux", "-S", sock, *args], env=env, text=True, stderr=subprocess.PIPE, timeout=3).strip()
def until(fn, label):
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
for proc, master, slave in clients:
try:
while os.read(master, 65536): pass
except (BlockingIOError, OSError): pass
if fn(): return
time.sleep(.04)
raise AssertionError(label + "; actual=" + call("display-message", "-p", "-t", "seat:", "#{window_width}x#{window_height}"))
def tile(cols, rows):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def setup():
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
proc = subprocess.Popen([sock if a == "TILE_SOCKET" else a for a in argv], env=env,
stdin=slave, stdout=slave, stderr=slave, preexec_fn=setup)
os.set_blocking(master, False)
clients.append((proc, master, slave))
return clients[-1]
def size(client, cols, rows):
fcntl.ioctl(client[2], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
os.killpg(client[0].pid, signal.SIGWINCH)
def check(cols, rows):
until(lambda: call("display-message", "-p", "-t", "seat:", "#{window_width}x#{window_height}") == str(cols)+"x"+str(rows), "latest visible client sizes seat")
until(lambda: "PTY-COLS:"+str(cols) in call("capture-pane", "-p", "-t", "seat:"), "agent PTY observes width")
screen = call("capture-pane", "-p", "-t", "seat:")
assert "END-REFLOW" in screen.replace("\n", ""), "full new text must remain readable"
assert max(map(len, screen.splitlines())) <= cols, "new text wraps to visible columns"
assert call("show-options", "-w", "-v", "-t", "seat:", "window-size") == "", "no local pin"
return str(cols)+"x"+str(rows)
try:
program = os.path.join(root, "app.py")
received = os.path.join(root, "received")
with open(program, "w") as f:
f.write("import os,signal,sys,tty\ntty.setraw(0)\n"+
"def draw(*_):\n c=os.get_terminal_size().columns\n print('PTY-COLS:'+str(c),flush=True)\n print('BEGIN-REFLOW '+('words '*35)+'END-REFLOW',flush=True)\n"+
"signal.signal(signal.SIGWINCH,draw)\ndraw()\nprint('\\x1b[?2004h',end='',flush=True)\n"+
"with open("+repr(received)+",'ab',buffering=0) as out:\n while True:\n out.write(os.read(0,65536))\n")
call("-f", "/dev/null", "new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=reflow-fixture", "python3 -u "+program)
call("set-option", "-g", "status", "off")
phone = tile(40, 24)
observed = [check(40, 24)]
laptop = tile(120, 35)
observed.append(check(120, 35))
for cols, rows in [(160,45),(52,28),(132,40)]:
size(laptop, cols, rows)
observed.append(check(cols, rows))
os.write(phone[1], b"p")
observed.append(check(40,24))
os.write(laptop[1], b"l")
observed.append(check(132,40))
def got():
with open(received, "rb") as f: return f.read()
until(lambda: got() == b"pl", "normal native input reaches seat")
# The first burst into a new native client, split inside UTF-8, then arrows and control keys.
first = tile(132, 40)
until(lambda: len(call("list-clients", "-F", "#{client_tty}").splitlines()) == 3, "native attach ready")
os.write(first[1], b"A"*63+b"\xc3")
time.sleep(.3)
os.write(first[1], b"\xa9END\x1b[A\x00\x13\x11")
expected = b"pl"+b"A"*63+b"\xc3\xa9END\x1b[A\x00\x13\x11"
until(lambda: got() == expected, "first keys arrive byte-for-byte and in order")
# Native tmux, rather than a custom first-key buffer, carries a framed paste over 256 KiB.
data = b"\x1b[200~"+b"".join(b"line %06d of a long paste\r" % i for i in range(16000))+b"\x1b[201~"
sent, deadline = 0, time.monotonic()+30
while sent < len(data):
try: sent += os.write(first[1], data[sent:sent+4096])
except BlockingIOError: time.sleep(.002)
assert time.monotonic() < deadline, "native paste must keep moving"
expected += data
until(lambda: got() == expected, "large paste arrives whole and framed")
# This is a new keypress after the paste, not part of its sub-millisecond burst.
# tmux's default assume-paste-time bypasses bindings for such bursts, even on
# a direct native attach. Respect that fixture option without changing it.
time.sleep((int(call("show-options", "-gv", "assume-paste-time")) + 20) / 1000)
os.write(first[1], b"\x02\x1b[5~")
until(lambda: call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "1", "prefix then PageUp enters native copy mode")
assert got() == expected, "prefix and PageUp do not reach the agent"
print(json.dumps({"sizes":observed,"localPin":"unset","nativeInput":True,"firstKeys":True,"pasteBytes":len(data),"prefixCopyMode":True}))
finally:
subprocess.run(["tmux","-S",sock,"kill-server"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for proc, master, slave in clients:
os.close(master); os.close(slave)
try: proc.wait(timeout=4)
except subprocess.TimeoutExpired: proc.terminate(); proc.wait(timeout=3)
shutil.rmtree(root)
+4 -202
View File
@@ -16,27 +16,6 @@ import time
shell, writable, readonly = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
tmux = shutil.which("tmux")
def version_at_least(version, wanted):
"""Whether tmux -V's version is at least wanted, (major, minor). next-* and master count as the newest."""
v = version[len("tmux "):] if version.startswith("tmux ") else version
if v.startswith("next-") or v == "master":
return True
major, _, rest = v.partition(".")
minor = ""
for ch in rest:
if not ch.isdigit():
break
minor += ch
return major.isdigit() and minor != "" and (int(major), int(minor)) >= wanted
TMUX_VERSION = subprocess.run([tmux, "-V"], capture_output=True, text=True).stdout.strip()
# From tmux 3.5 a tile sends its first keys through the attaching client, so tmux's prefix applies to them. Below 3.5 they
# go to the pane, as tile-attach decides from tmux -V.
THROUGH_CLIENT = version_at_least(TMUX_VERSION, (3, 5))
# Before 3.4, tmux's own client start writes paste framing off, so the tile's framing before the attach can't be told apart.
FRAMING_GAP_SEEN = version_at_least(TMUX_VERSION, (3, 4))
# Wider than any tile below, so a tile that attached would show only the start of it.
LINE = "START " + " ".join(str(i) for i in range(1, 45)) + " END"
# A rule as wide as the seat's window, as Claude Code draws them.
@@ -178,185 +157,8 @@ def read_only_beside_another_client(server):
return {"window": server.geometry()}
def writable_tile(server):
tile = server.tile(writable, 40, 30)
server.wait_for(lambda: LINE in server.seen(tile), "a writable tile shows the whole line before anyone types")
# Every key is read as a key: no flow control, and no other special handling.
iflags, flags = termios.tcgetattr(tile[2])[0], termios.tcgetattr(tile[2])[3]
assert not iflags & termios.IXON and not flags & termios.IEXTEN, "a showing tile must read every key"
assert server.client_ttys() == [] and server.geometry() == "160x20", "an unused writable tile is no client"
# The first key reaches the seat, and the tile attaches for the rest.
os.write(tile[1], b"hello")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile")
server.wait_for(lambda: "hello" in server.seat_screen(), "the first key reaches the seat")
os.write(tile[1], b" world")
server.wait_for(lambda: "hello world" in server.seat_screen(), "typing in the attached tile reaches the seat")
# The tile sizes the seat while it's used, as any client the person types in does.
server.wait_for(lambda: server.geometry() == "40x30", "the tile in use sizes the seat")
used = server.geometry()
# Idle for the tile's idle time (2 s here): it detaches back to the view.
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# After tmux's detach line, the view redraws the seat's screen itself.
def viewing_again():
after = server.seen(tile).rpartition("[detached")[2]
return "\x1b[H\x1b[2J" in after and "hello world" in after.rpartition("\x1b[H\x1b[2J")[2]
server.wait_for(viewing_again, "the detached tile views the seat again")
# Enter as the first key goes to the seat, and attaches the tile again.
os.write(tile[1], b"\r")
server.wait_for(lambda: server.seat_screen().count("hello world") == 2, "Enter as the first key reaches the seat")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile again")
return {"used": used}
def recorder(server):
# The seat asks for bracketed paste, as Claude Code does, and records every byte it's given, as a raw terminal
# delivers them.
return "printf \"\\033[?2004h\"; stty raw -echo; exec dd bs=1 of=" + server.socket + ".got 2>/dev/null"
def received(server):
try:
return open(server.socket + ".got", "rb").read()
except FileNotFoundError:
return b""
def writable_first_input(server):
tile = server.tile(writable, 40, 30)
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
# A paste that breaks inside a UTF-8 character, the rest arriving after the break.
os.write(tile[1], b"A" * 63 + b"\xc3")
time.sleep(0.3)
os.write(tile[1], b"\xa9END")
expected = b"A" * 63 + "\u00e9".encode() + b"END"
server.wait_for(lambda: received(server) == expected, "the first input reaches the seat byte for byte")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first input attaches the tile")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# Ctrl-Space types a NUL.
os.write(tile[1], b"\x00")
expected += b"\x00"
server.wait_for(lambda: received(server) == expected, "a NUL first key reaches the seat")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "a NUL first key attaches the tile")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
if THROUGH_CLIENT:
# tmux's prefix works as in an attached tile: Ctrl-b d detaches at once, and nothing reaches the seat.
os.write(tile[1], b"\x02d")
server.wait_for(lambda: "[detached" in server.seen(tile).rpartition("\x1b[?2004h")[0][-400:] or
server.seen(tile).count("[detached") >= 3, "Ctrl-b d detaches the tile")
time.sleep(0.5)
assert received(server) == expected, "the prefix and its key must not reach the seat: " + repr(received(server)[len(expected):])
else:
# Below tmux 3.5 the first keys go to the pane before the tile attaches, so a prefix typed first reaches the seat.
os.write(tile[1], b"\x02d")
expected += b"\x02d"
server.wait_for(lambda: received(server) == expected, "below tmux 3.5, a prefix typed first reaches the seat")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "below tmux 3.5, the first keys attach the tile")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# A paste while showing: the tile's terminal frames it only if the tile asked for that, as a terminal does.
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
paste = b"first line\rsecond line"
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
expected += b"\x1b[200~" + paste + b"\x1b[201~"
server.wait_for(lambda: received(server) == expected, "a paste reaches the seat as one paste")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# Ctrl-S and Ctrl-Q are keys like any other.
os.write(tile[1], b"\x13\x11")
expected += b"\x13\x11"
server.wait_for(lambda: received(server) == expected, "Ctrl-S and Ctrl-Q reach the seat")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "Ctrl-S and Ctrl-Q attach the tile")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# A key and, at once, a paste: the paste is still framed, and both arrive in order.
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
mark = len(server.seen(tile))
os.write(tile[1], b"A")
time.sleep(0.03)
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
expected += b"A\x1b[200~" + paste + b"\x1b[201~"
server.wait_for(lambda: received(server) == expected, "a key then a paste arrive in order, the paste framed",
detail=lambda: "framed=%r; seat got %r; expected %r" % (framed, received(server)[-80:], expected[-80:]))
# From the first key until tmux takes the terminal, its paste framing stays on: a paste in that gap stays one paste.
server.wait_for(lambda: "\x1b[?2004h" in server.seen(tile)[mark:], "tmux takes the tile's terminal")
gap = server.seen(tile)[mark:].split("\x1b[?2004h", 1)[0]
assert not FRAMING_GAP_SEEN or "\x1b[?2004l" not in gap, "the tile turned paste framing off before tmux attached: " + repr(gap[-80:])
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# A run of text that ends in ; keeps it, through the client and before a paste alike.
os.write(tile[1], b"ls;\r")
expected += b"ls;\r"
server.wait_for(lambda: received(server) == expected, "a trailing ; reaches the seat")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
os.write(tile[1], b"x;\x1b[200~" + paste + b"\x1b[201~")
expected += b"x;\x1b[200~" + paste + b"\x1b[201~"
server.wait_for(lambda: received(server) == expected, "a trailing ; before a paste reaches the seat")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
# An arrow as the first key reaches the agent as typed.
os.write(tile[1], b"\x1b[A")
expected += b"\x1b[A"
server.wait_for(lambda: received(server) == expected, "Up as the first key reaches the seat as Up")
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
if not THROUGH_CLIENT:
# Below tmux 3.5 the prefix typed first reaches the seat (above), so the prefix-then-PgUp case applies from 3.5 on.
return {"bytes": len(received(server))}
# The prefix, then PgUp as a person types them: tmux scrolls back, and nothing reaches the seat.
os.write(tile[1], b"\x02")
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the prefix attaches the tile")
os.write(tile[1], b"\x1b[5~")
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "1", "Ctrl-b PgUp opens copy mode")
os.write(tile[1], b"q")
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "0", "q leaves copy mode")
time.sleep(0.3)
assert received(server) == expected, "Ctrl-b PgUp must not type into the seat: " + repr(received(server)[len(expected):])
return {"bytes": len(received(server))}
def writable_big_paste(server):
tile = server.tile(writable, 40, 30)
server.wait_for(lambda: "\x1b[?2004h" in server.seen(tile), "the tile frames pastes while it shows")
# A paste of about 450 KB, framed as a terminal frames it, written as fast as the tile reads it.
data = b"\x1b[200~" + b"".join(b"line %06d of a long paste\r" % i for i in range(16000)) + b"\x1b[201~"
sent, deadline = 0, time.monotonic() + 90
while sent < len(data):
try:
sent += os.write(tile[1], data[sent:sent + 4096])
except BlockingIOError:
time.sleep(0.002)
server.drain()
assert time.monotonic() < deadline, "the tile must keep reading the paste"
server.wait_for(lambda: received(server) == data, "a paste over 256 KiB reaches the seat whole and framed", 90)
server.wait_for(lambda: server.client_ttys() == [], "the tile detaches when idle after the paste", 10)
return {"bytes": len(data)}
def writable_first_input_window(server):
# The attach is slowed to a second, so keys typed after the first burst land before tmux takes the terminal.
slow = server.socket + ".slow-tmux"
open(slow, "w").write("#!/bin/sh\ncase \" $* \" in *\" attach \"*) sleep 1;; esac\nexec " + tmux + " \"$@\"\n")
os.chmod(slow, 0o700)
argv = list(writable)
argv[5] = slow
tile = server.tile(argv, 40, 30)
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
os.write(tile[1], b"y")
time.sleep(0.5)
# Enter and Ctrl-C, before the attach completes.
os.write(tile[1], b"\r\x03")
server.wait_for(lambda: received(server) == b"y\r\x03", "Enter and Ctrl-C before the attach reach the seat as typed")
assert tile[0].poll() is None, "Ctrl-C before the attach must not end the tile"
return {"bytes": len(received(server))}
with tempfile.TemporaryDirectory(prefix="tile-size-") as temporary:
root = os.path.realpath(temporary)
if sys.argv[4:] == ["big-paste"]:
print(json.dumps({"bigPaste": cell(root, "f", writable_big_paste, recorder)}))
sys.exit(0)
result = {
"readOnlyAlone": cell(root, "a", read_only_alone),
"readOnlyBesideAnother": cell(root, "b", read_only_beside_another_client),
"writable": cell(root, "c", writable_tile),
"firstInput": cell(root, "d", writable_first_input, recorder),
"firstInputWindow": cell(root, "e", writable_first_input_window, recorder),
}
print(json.dumps(result))
print(json.dumps({
"readOnlyAlone": cell(temporary, "a", read_only_alone),
"readOnlyBesideAnother": cell(temporary, "b", read_only_beside_another_client),
}))
@@ -24,10 +24,8 @@ describe.skipIf(!tmuxBin)("the generated herdr-app viewer (real tmux): nothing r
const paste = async (text: string) => { await tmux(view, "set-buffer", "-b", "p", text); await tmux(view, "paste-buffer", "-p", "-b", "p", "-t", "v1"); };
const until = async (check: () => Promise<boolean>, ms = 8_000) => { for (const end = Date.now() + ms; Date.now() < end;) { if (await check()) return true; await new Promise((r) => setTimeout(r, 100)); } return false; };
const gateAt = (path: string, seconds = 3_600) => ({ path, until: Math.floor(Date.now() / 1000) + seconds, expired: "WINDOW-PASSED: reopen it with rig terminal open rig:demo" });
// Which tile runs in the viewer, and whether it's ready: a process under the viewer's pane whose own argv starts with a
// tile script (the wrapper's argv carries both scripts as arguments, so it never matches). The read-only tile is ready
// once it loops in its view (a sleep child, after its stty -echo); the writable one once it waits on its input (a dd
// child, after raw mode and paste framing are on).
// The read-only tile is ready once its own script loops in its view (a sleep child, after stty -echo).
// The writable tile is ready once a native tmux client under this viewer is attached to the seat server.
const tileReady = async (kind: "read-only" | "writable") => {
const pid = (await tmux(view, "display-message", "-p", "-t", "v1", "#{pane_pid}")).stdout.trim();
const procs = (await run("ps", ["-e", "-ww", "-o", "pid=,ppid=,args="])).stdout.split("\n")
@@ -35,10 +33,12 @@ describe.skipIf(!tmuxBin)("the generated herdr-app viewer (real tmux): nothing r
.map(([, p, pp, args]) => ({ pid: p!, ppid: pp!, args: args! }));
const under = new Set([pid]);
for (let grew = true; grew;) { grew = false; for (const p of procs) if (under.has(p.ppid) && !under.has(p.pid)) { under.add(p.pid); grew = true; } }
const marker = kind === "writable" ? "while use" : "stty -echo -isig -ixon";
const tile = procs.find((p) => under.has(p.pid) && p.args.startsWith("/bin/sh -c umask 077") && p.args.includes(marker));
const waits = kind === "writable" ? /^dd / : /^sleep /;
return !!tile && procs.some((p) => p.ppid === tile.pid && waits.test(p.args));
if (kind === "writable") {
const attached = (await tmux(seat, "list-clients", "-F", "#{client_pid}")).stdout.trim().split("\n");
return attached.some((clientPid) => under.has(clientPid));
}
const tile = procs.find((p) => under.has(p.pid) && p.args.startsWith("/bin/sh -c umask 077") && p.args.includes("stty -echo -isig -ixon"));
return !!tile && procs.some((p) => p.ppid === tile.pid && /^sleep /.test(p.args));
};
const start = async (argv: string[]) => {
await tmux(view, "-f", "/dev/null", "new-session", "-d", "-s", "v1", "-x", "80", "-y", "20", ...argv);
@@ -83,8 +83,7 @@ describe.skipIf(!tmuxBin)("the generated herdr-app viewer (real tmux): nothing r
await paste("AFTER-PASTE");
expect(await until(async () => (await shown()).includes("AFTER-PASTE"))).toBe(true);
expect(await shown()).not.toMatch(/BEFORE/); // dropped, never delivered late
// The viewer's own writable tile attaches. The tile puts a paste into the pane first and attaches after, so the
// client can show up a moment after the text.
// The viewer's own native writable client stays attached.
expect(await until(async () => (await clients()).length === 1)).toBe(true);
}, 30_000);
@@ -570,7 +570,7 @@ it("keeps a real empty mission workspace enabled through Core with a mission wai
describe("follow views kept from an earlier release", () => {
const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'";
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"a writable follow view kept from %s still adds and closes tiles, and moves to the current script", async (_, make) => {
"a kept %s follow view reuses sizing clients or names the explicit reopen step", async (_, make) => {
const f = fixture(); expect(await f.follow()).toMatchObject({ ok: true, opened: ["a", "b", "c"] });
// Rewrite the saved canvas and the viewer journal as that release left them.
const earlier = (seat: string) => make("/usr/bin/tmux", ["-S", "/tmp/follow-seats.sock"], seat, false);
@@ -584,6 +584,13 @@ describe("follow views kept from an earlier release", () => {
}
writeFileSync(f.journal(), JSON.stringify(journal));
if (make !== tileAttachArgv0710) {
const before = f.calls.length;
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" }, error: expect.stringContaining("Close only the owned Herdr viewer pane") });
expect(f.calls.slice(before).some(c => ["create", "split", "run", "close"].includes(c[1]!))).toBe(false);
expect(JSON.parse(readFileSync(canvasFile, "utf8")).tiles.map((t: { target: unknown }) => t.target)).toEqual(canvas.tiles.map((t: { target: unknown }) => t.target));
return; // An operator closes the old viewer and opens a fresh ordinary view; no live-client migration claim.
}
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
await f.follow(["a", "b", "d"]); f.advance(4000);
expect(await f.follow(["a", "b", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "d"] });
+24 -17
View File
@@ -242,17 +242,11 @@ describe("owned Herdr viewer", () => {
expect(await new HerdrViewer(socket, async () => { throw new Error("should not run"); }, "win32", f.root).ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unsupported" });
expect(await new HerdrViewer(socket, async () => JSON.stringify({ running: true, version: "0.9.5", protocol: 22, socket: "/other.sock" }), "linux", f.root).ensure(key, f.target)).toMatchObject({ ok: false });
});
it("a writable tile attaches, sizing, only from its one typing path; a read-only tile never attaches; recovery still attaches", () => {
it("a writable tile attaches immediately and sizes natively; a read-only tile remains an observer", () => {
const rw = tileAttachArgv("tmux", [], "a@team", false)[2]!;
// Both attaches are the typing path's: a plain one (also after a paste), and one that carries the first keys.
expect(rw.match(/attach -t/g)).toEqual(['attach -t', 'attach -t']);
expect(rw).toContain('then pz=1; put "$@"; fi;');
expect(rw).toContain('then if [ -n "$kk" ]; then ng=; go "$@"; else kz 0 $(($(wc -c < "$f"))) "$@"; fi; fi; [ -z "$ng" ] || "$@" attach -t "$t";');
expect(rw).toContain('go() { set -- "$@" attach -t "$t";');
// The first input's files are readable by this user alone.
expect(rw.startsWith("umask 077;")).toBe(true);
expect(rw.match(/open "/g)).toHaveLength(4);
expect(rw).toBe(tileAttachArgv0710("tmux", [], "a@team", false)[2]);
expect(rw).not.toContain("ignore-size");
expect(rw).not.toContain("use()");
const ro = tileAttachArgv("tmux", [], "a@team", true)[2]!;
expect(ro).toContain('open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };');
expect(ro).not.toMatch(/attach -r|while use/);
@@ -335,15 +329,28 @@ describe("viewers kept from an earlier release", () => {
return () => JSON.parse(readFileSync(file, "utf8")).entries[id] as { command: string; argv: string[] };
}
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"keeps a writable viewer recorded by %s and moves it to the current script", async (_, make) => {
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
const stored = keepFrom(f, make, false);
expect(stored().command).not.toBe(f.target.paneCommand);
expect(await f.viewer().lookup(key, f.target)).toMatchObject({ ok: true });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
expect(stored()).toMatchObject({ command: f.target.paneCommand, argv: f.target.attachArgv });
it("keeps 0.7.10's already-sizing writable viewer unchanged", async () => {
const f = fixture(); await f.viewer().ensure(key, f.target);
const stored = keepFrom(f, tileAttachArgv0710, false);
expect(stored().command).toBe(f.target.paneCommand);
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
});
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"names the close-owned-viewer step for a kept %s writable client, without pretending argv migrated it", async (_, make) => {
const f = fixture(); await f.viewer().ensure(key, f.target);
const stored = keepFrom(f, make, false), before = stored();
const result = await f.viewer().ensure(key, f.target);
expect(result).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
expect(result.ok ? "" : result.error).toContain("Close only the owned Herdr viewer pane");
expect(stored()).toEqual(before);
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
f.lose(false); // the person closed that viewer; complete endpoint enumeration proves absence
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ code: "viewer_not_provisioned" });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: true });
expect(stored()).toMatchObject({ command: f.target.paneCommand, argv: f.target.attachArgv });
expect(f.calls.filter(a => a[0] === "workspace" && a[1] === "delete")).toHaveLength(0);
});
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
@@ -0,0 +1,17 @@
// #2794: `rig queue show` finds a handed-off row's successor by its handed_off_from. Migration 159 indexes that column
// (partially: only successor rows carry it), so the lookup is a seek, not a scan of the whole queue.
import { describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
describe("queue_items.handed_off_from index (migration 159)", () => {
it("a successor lookup by handed_off_from uses the index", () => {
const db = new Database(":memory:");
try {
migrate(db, ALL_MIGRATIONS);
const plan = db.prepare("EXPLAIN QUERY PLAN SELECT qitem_id FROM queue_items WHERE handed_off_from = ?").all("qitem-x") as Array<{ detail: string }>;
expect(plan.map((row) => row.detail).join(" ")).toContain("idx_queue_items_handed_off_from");
} finally { db.close(); }
});
});
+104
View File
@@ -850,6 +850,110 @@ describe("queue routes", () => {
});
});
describe("GET /:qitemId names a handed-off row's successor (#2794)", () => {
const show = async (qitemId: string) => (await app.request(`/api/queue/${qitemId}`)).json() as Promise<Record<string, any>>;
const stored = (qitemId: string) => db.prepare("SELECT state, closure_reason, closure_target, handed_off_to FROM queue_items WHERE qitem_id = ?").get(qitemId);
it("a handoff names the row it made, and the closed row is stored as before", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
const { created } = await queueRepo.handoff({ qitemId: source.qitemId, fromSession: "b@r", toSession: "next@r", nudge: false });
const item = await show(source.qitemId);
expect(item.closureTarget).toBe("next@r");
expect(item.successors).toEqual([{ qitemId: created.qitemId, destinationSession: "next@r", state: "pending", via: "handedOffFrom" }]);
expect(item).not.toHaveProperty("successorNote");
expect(item).not.toHaveProperty("successorsTruncated");
// The read changes nothing stored: the closure target still names the new owner, as the docs say.
expect(stored(source.qitemId)).toEqual({ state: "handed-off", closure_reason: "handed_off_to", closure_target: "next@r", handed_off_to: "next@r" });
});
it("handoff-and-complete (a done row) names the row it made, and its state as it is now", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
queueRepo.claim({ qitemId: source.qitemId, destinationSession: "b@r" });
const { closed, created } = await queueRepo.handoffAndComplete({ qitemId: source.qitemId, fromSession: "b@r", toSession: "next@r", nudge: false });
expect(closed.state).toBe("done");
queueRepo.claim({ qitemId: created.qitemId, destinationSession: "next@r" });
expect((await show(source.qitemId)).successors).toEqual([{ qitemId: created.qitemId, destinationSession: "next@r", state: "in-progress", via: "handedOffFrom" }]);
});
it("past 20 successors, the newest 20 are named and successorsTruncated says the list is cut", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = 'next@r', handed_off_to = 'next@r' WHERE qitem_id = ?").run(source.qitemId);
const insert = db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body, handed_off_from)
VALUES (?, ?, ?, 'b@r', 'next@r', 'pending', 'routine', 'x', ?)`);
const id = (n: number) => `qitem-succ-${String(n).padStart(2, "0")}`;
const add = (n: number) => { const ts = `2026-11-10T00:00:${String(n).padStart(2, "0")}.000Z`; insert.run(id(n), ts, ts, source.qitemId); };
for (let n = 1; n <= 20; n++) add(n);
const full = await show(source.qitemId);
expect(full.successors).toHaveLength(20);
expect(full).not.toHaveProperty("successorsTruncated");
add(21);
const cut = await show(source.qitemId);
expect(cut.successors.map((s: { qitemId: string }) => s.qitemId)).toEqual(Array.from({ length: 20 }, (_, i) => id(i + 2)));
expect(cut.successorsTruncated).toBe(true);
expect(cut).not.toHaveProperty("successorNote");
});
it("a close whose target is a local row id names that row", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
const reply = await queueRepo.create({ sourceSession: "b@r", destinationSession: "a@r", body: "the return", nudge: false });
queueRepo.claim({ qitemId: source.qitemId, destinationSession: "b@r" });
const res = await app.request(`/api/queue/${source.qitemId}/update`, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "b@r" },
body: JSON.stringify({ state: "done", closureReason: "handed_off_to", closureTarget: reply.qitemId }),
});
expect(res.status).toBe(200);
expect((await show(source.qitemId)).successors).toEqual([{ qitemId: reply.qitemId, destinationSession: "a@r", state: "pending", via: "closureTarget" }]);
});
it("a cross-host handoff names its successor as recorded, without looking it up remotely", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = ?, handed_off_to = ? WHERE qitem_id = ?")
.run("qitem-xh-0123456789abcdef@far-host", "next@r", source.qitemId);
const item = await show(source.qitemId);
expect(item.successors).toEqual([]);
expect(item.successorNote).toBe("It was handed to another host; its successor there is qitem-xh-0123456789abcdef@far-host. This daemon has no copy.");
});
it("a handoff with no successor on this daemon says so", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
queueRepo.claim({ qitemId: source.qitemId, destinationSession: "b@r" });
await app.request(`/api/queue/${source.qitemId}/update`, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "b@r" },
body: JSON.stringify({ state: "done", closureReason: "handed_off_to", closureTarget: "next@r" }),
});
const item = await show(source.qitemId);
expect(item.successors).toEqual([]);
expect(item.successorNote).toBe("No row on this daemon was made by this handoff.");
});
it("a lookup that fails still answers the read, and says the successor couldn't be looked up", async () => {
const source = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
await queueRepo.handoff({ qitemId: source.qitemId, fromSession: "b@r", toSession: "next@r", nudge: false });
const list = vi.spyOn(queueRepo, "list").mockImplementation(() => { throw Object.assign(new Error("busy"), { code: "SQLITE_BUSY" }); });
try {
const res = await app.request(`/api/queue/${source.qitemId}`);
expect(res.status).toBe(200);
const item = await res.json();
expect(item.state).toBe("handed-off");
expect(item.successors).toEqual([]);
expect(item.successorNote).toBe("Its successor couldn't be looked up; the closure target is as recorded.");
} finally { list.mockRestore(); }
});
it("an open row, or a row closed another way, carries no successor fields", async () => {
const open = await queueRepo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", nudge: false });
expect(await show(open.qitemId)).not.toHaveProperty("successors");
queueRepo.claim({ qitemId: open.qitemId, destinationSession: "b@r" });
await app.request(`/api/queue/${open.qitemId}/update`, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "b@r" },
body: JSON.stringify({ state: "done", closureReason: "no-follow-on", transitionNote: "finished" }),
});
const done = await show(open.qitemId);
expect(done).not.toHaveProperty("successors");
expect(done).not.toHaveProperty("successorNote");
});
});
describe("GET /:qitemId closing transition", () => {
const update = (qitemId: string, body: Record<string, unknown>) => app.request(`/api/queue/${qitemId}/update`, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "b@r" },
@@ -44,7 +44,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
/** One browser viewer comes and goes through the broker. */
async function browserVisit(name: string, adapter = new TmuxAdapter(exec)): Promise<void> {
let empty = false;
const broker = new TerminalSessionBroker(name, adapter, { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
const broker = new TerminalSessionBroker(name, adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
const browser = viewer();
await broker.attach(browser);
expect(await size(name)).toBe("90x27"); // the fixed geometry while a browser watches
@@ -52,6 +52,22 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
await until(async () => empty);
}
it("a default browser stays open while the native client resizes the agent PTY", async () => {
await nativeSession("default-web");
const client = clients.at(-1)!;
const broker = new TerminalSessionBroker("default-web", new TmuxAdapter(exec), { pollMs: 20 });
try {
await broker.attach(viewer());
expect(await ownSizing("default-web")).toBe("");
expect(await size("default-web")).toBe("150x45");
client.stdin!.write("refresh-client -C 52x28\n");
await until(async () => (await size("default-web")) === "52x28");
client.stdin!.write("refresh-client -C 132x40\n");
await until(async () => (await size("default-web")) === "132x40");
expect(await ownSizing("default-web")).toBe("");
} finally { await broker.dispose(); }
});
it("a window that inherited its sizing inherits again, and the native client's size comes back", async () => {
await nativeSession("inherits");
await browserVisit("inherits");
@@ -59,10 +75,24 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
await until(async () => (await size("inherits")) === "150x45");
});
it("a window with its own sizing keeps that value", async () => {
it("an opt-in browser preserves a person's manual geometry with no OpenRig pin record", async () => {
await nativeSession("user-manual");
await tmux("resize-window", "-t", "=user-manual:", "-x", "113", "-y", "31");
const broker = new TerminalSessionBroker("user-manual", new TmuxAdapter(exec), { fixedGeometry: true, pollMs: 20 });
try {
await broker.attach(viewer());
expect(await size("user-manual")).toBe("113x31");
expect(await ownSizing("user-manual")).toBe("manual");
expect(await record("user-manual")).toBe("");
} finally { await broker.dispose(); }
expect(await size("user-manual")).toBe("113x31");
expect(await ownSizing("user-manual")).toBe("manual");
});
it("an opt-in pin releases to inherited sizing even after an old local override", async () => {
await nativeSession("own", "largest");
await browserVisit("own");
expect(await ownSizing("own")).toBe("largest");
expect(await ownSizing("own")).toBe("");
await until(async () => (await size("own")) === "150x45");
});
@@ -70,7 +100,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
await nativeSession("switch");
const pinned = await tmux("display-message", "-p", "-t", "=switch:", "#{window_id}");
let empty = false;
const broker = new TerminalSessionBroker("switch", new TmuxAdapter(exec), { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
const broker = new TerminalSessionBroker("switch", new TmuxAdapter(exec), { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty = true; } });
const browser = viewer();
await broker.attach(browser);
const other = await tmux("new-window", "-d", "-P", "-F", "#{window_id}", "-t", "=switch:");
@@ -97,7 +127,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
expect(await ownSizing(name)).toBe("manual");
expect(await size(name)).toBe("90x27");
await browserVisit(name);
expect(await ownSizing(name)).toBe(own ?? "");
expect(await ownSizing(name)).toBe("");
expect(await record(name)).toBe("");
await until(async () => (await size(name)) === "150x45");
});
@@ -110,7 +140,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
}
let empty = false;
const closed: number[] = [];
const broker = new TerminalSessionBroker("probe", new Unreachable(exec), { pollMs: 20, livenessMs: 50, onEmpty: () => { empty = true; } });
const broker = new TerminalSessionBroker("probe", new Unreachable(exec), { fixedGeometry: true, pollMs: 20, livenessMs: 50, onEmpty: () => { empty = true; } });
await broker.attach({ send: () => {}, close: (code) => { closed.push(code); } });
expect(await size("probe")).toBe("90x27");
reachable = false;
@@ -125,7 +155,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
await nativeSession(name, "largest");
const adapter = new TmuxAdapter(exec);
const empty = [false, false];
const brokers = [0, 1].map((i) => new TerminalSessionBroker(name, adapter, { pollMs: 20, livenessMs: 1000, onEmpty: () => { empty[i] = true; } }));
const brokers = [0, 1].map((i) => new TerminalSessionBroker(name, adapter, { fixedGeometry: true, pollMs: 20, livenessMs: 1000, onEmpty: () => { empty[i] = true; } }));
const browsers = [viewer(), viewer()];
await brokers[0].attach(browsers[0]);
await brokers[1].attach(browsers[1]);
@@ -136,7 +166,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
expect(await size(name)).toBe("90x27");
brokers[staying].detach(browsers[staying]);
await until(async () => empty[staying]);
expect(await ownSizing(name)).toBe("largest");
expect(await ownSizing(name)).toBe("");
expect(await record(name)).toBe("");
await until(async () => (await size(name)) === "150x45");
});
@@ -149,7 +179,7 @@ describe.skipIf(!available)("web terminal window sizing with a native client (re
try {
await t("new-session", "-d", "-s", "reborn", "-x", "120", "-y", "40");
let empty = false;
const broker = new TerminalSessionBroker("reborn", new TmuxAdapter(execOn), { pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
const broker = new TerminalSessionBroker("reborn", new TmuxAdapter(execOn), { fixedGeometry: true, pollMs: 20, livenessMs: 60000, onEmpty: () => { empty = true; } });
const browser = viewer();
await broker.attach(browser);
const pinned = await t("display-message", "-p", "-t", "=reborn:", "#{window_id}");
@@ -34,7 +34,7 @@ it.each(["inherit", "isolated"] as const)("keeps remote session names intact thr
});
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
: [`=${session}:`, "session-id"]);
: [`=${session}:`, ...(readOnly ? [] : ["attach", "-t"]), "session-id"]);
expect(view.opened[0]!.paneCommand).not.toContain("/local/only/socket");
writeFileSync(argsFile, "");
execFileSync("/bin/sh", ["-c", view.opened[0]!.recoveryCommand!], {
@@ -60,9 +60,9 @@ it.skipIf(process.platform === "win32")("keeps all four terminal tiles on their
await viewer(["select-layout", "-t", "=view:", "tiled"]);
}
const shows = async (i: number, text: string) => (await viewer(["capture-pane", "-p", "-t", panes[i]!])).includes(text);
// Each tile views its own seat, and none of them is a client of it.
// Writable tiles are sizing clients; the read-only tile remains an observer.
for (let i = 0; i < 4; i++) await until(() => shows(i, `SEAT${i}`));
expect(await seats(["list-clients", "-F", "#{client_tty}"])).toBe("");
expect((await seats(["list-clients", "-F", "#{session_name}"])).split("\n").sort()).toEqual(names.slice(0, 3));
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
await create(1, "REPLACED_SEAT1");
await until(() => shows(1, "REPLACED_SEAT1"));
@@ -139,7 +139,7 @@ it.skipIf(process.platform === "win32").each([false, true])("a read-only tile st
it.skipIf(process.platform === "win32")("stops showing a session whose seat identity changed in place", async () => {
await fixture(async ({ socket, seats, viewer }) => {
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
const command = tileAttachArgv("tmux", ["-S", socket], "seat", true, 2).map(shellQuote).join(" ");
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
await seats(["set-environment", "-t", "=seat", "OPENRIG_NODE_ID", "DIFFERENT", ";", "send-keys", "-t", "=seat:", "-l", "OTHER-SEAT"]);
@@ -148,7 +148,7 @@ it.skipIf(process.platform === "win32")("stops showing a session whose seat iden
});
});
it.skipIf(process.platform === "win32")("a detach returns a tile someone typed in to viewing its seat", async () => {
it.skipIf(process.platform === "win32")("an ordinary detach ends the sizing tile while preserving its seat", async () => {
await fixture(async ({ socket, seats, viewer }) => {
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
@@ -159,9 +159,9 @@ it.skipIf(process.platform === "win32")("a detach returns a tile someone typed i
await until(async () => (await seats(["capture-pane", "-p", "-t", "=seat:"])).includes("typed"));
await seats(["detach-client", "-s", "=seat"]);
await until(async () => (await seats(["list-clients", "-F", "#{session_name}"])) === "");
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("typed"));
expect(await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])).toBe("0");
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("No replacement");
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
expect(await seats(["has-session", "-t", "=seat"])).toBe("");
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).toContain("reopen the view");
});
});
@@ -50,6 +50,14 @@ function track(b: TerminalSessionBroker): TerminalSessionBroker {
liveBrokers.push(b);
return b;
}
it("a default web viewer does not pin or resize the native client's window", async () => {
const setWindowOption = vi.fn(async () => ({ ok: true as const }));
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
const b = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow })));
await b.attach(makeSub());
expect(setWindowOption).not.toHaveBeenCalled();
expect(resizeWindow).not.toHaveBeenCalled();
});
afterEach(() => {
for (const b of liveBrokers.splice(0)) b.dispose();
});
@@ -250,11 +258,11 @@ describe("TerminalSessionBroker", () => {
expect(resizeWindow).not.toHaveBeenCalled();
});
it("test 7b: canonical geometry is set ONCE at open (window-size manual + 120xN, NOT aggressive-resize)", async () => {
it("test 7b: explicitly requested fixed geometry is set ONCE at open (window-size manual + 120xN, NOT aggressive-resize)", async () => {
const setWindowOption = vi.fn(async () => ({ ok: true as const }));
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow }), {
pollMs: 10,
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ setWindowOption, resizeWindow, readWindow: async () => ({ id: "@2", userOption: "" }), getWindowLocalOption: async () => null }), {
fixedGeometry: true, pollMs: 10,
cols: 120,
rows: 40,
}));
@@ -262,8 +270,8 @@ describe("TerminalSessionBroker", () => {
await broker.attach(makeSub());
expect(resizeWindow).toHaveBeenCalledOnce();
expect(resizeWindow).toHaveBeenCalledWith("dev@rig", 120, 40);
expect(setWindowOption).toHaveBeenCalledWith("dev@rig", "window-size", "manual");
expect(resizeWindow).toHaveBeenCalledWith("@2", 120, 40);
expect(setWindowOption).toHaveBeenCalledWith("@2", "window-size", "manual");
// aggressive-resize fights fixed geometry (shrinks to smallest client) — must NOT be set.
expect(setWindowOption).not.toHaveBeenCalledWith("dev@rig", "aggressive-resize", expect.anything());
});
@@ -949,9 +957,9 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", ()
});
});
// ---- the window's own sizing comes back when the broker lets go -------------
// While a browser is subscribed the window is pinned (window-size manual). When the last live holder lets go, the window
// gets back exactly what it had: its own value, or none so it inherits again, and native clients size it as before. The
// ---- explicit web pins release to inherited sizing --------------------------
// While a browser explicitly opts into fixed geometry the window is pinned (window-size manual). On last release,
// the local override is unset, so native clients size it under the inherited policy. The
// window is named by its id, and the pin is recorded on the window with its holders, so another live broker keeps it
// and a later broker gives back a pin whose holders are gone.
@@ -986,7 +994,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
}
function broker(tmux: BrokerTmux, onEmpty?: () => void): TerminalSessionBroker {
return new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, onEmpty });
return new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, onEmpty, fixedGeometry: true });
}
/** A viewer comes and goes; resolves once the broker is evicted. */
@@ -1002,7 +1010,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
it.each([
["the window's own value", "largest" as string | null],
["none (inherited)", null],
])("the last viewer leaving gives back %s to the pinned window, once", async (_, prior) => {
])("last opt-in viewer releases %s to inherited sizing, once", async (_, prior) => {
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
const { tmux, calls, window, restoreWindowOption } = sizingTmux(prior, { resizeWindow });
const evicted: string[] = [];
@@ -1020,11 +1028,11 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
expect(calls).toEqual([
`set:@2:@openrig-window-size-prior=${prior ?? "inherit"} H`,
"set:@2:window-size=manual",
`restore:@2:window-size=${prior}`,
"restore:@2:window-size=null",
"restore:@2:@openrig-window-size-prior=null",
"empty",
]);
expect(window).toEqual({ own: prior, recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
});
it.each(["first", "second"])("two brokers on one window: the pin lasts until the last leaves, whichever leaves %s", async (order) => {
@@ -1040,7 +1048,7 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
expect(window.own).toBe("manual"); // the other browser still watches: still pinned
expect(calls.filter((call) => call.startsWith("restore:"))).toEqual([]);
stays[0].detach(stays[1]);
await vi.waitFor(() => expect(window).toEqual({ own: "largest", recorded: "" }));
await vi.waitFor(() => expect(window).toEqual({ own: null, recorded: "" }));
});
it("a pin held by a broker in another live daemon is kept; this broker only drops itself", async () => {
@@ -1060,13 +1068,13 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
expect(norm(window.recorded)).toBe(`${record} H`);
expect(window.own).toBe("manual");
await visit(tmux);
expect(window).toEqual({ own: prior, recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
});
it("a pin whose holder's daemon is gone is given back by the next broker", async () => {
const { tmux, window } = sizingTmux("manual", {}, `largest ${deadPid}.1`);
await visit(tmux);
expect(window).toEqual({ own: "largest", recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
});
it("a window that no longer lists this broker (a restarted tmux server reusing @2) is left alone", async () => {
@@ -1095,22 +1103,41 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
expect(norm(window.recorded)).toBe("largest H");
const next = sizingTmux("manual", {}, window.recorded); // its holder is no longer live
await visit(next.tmux);
expect(next.window).toEqual({ own: "largest", recorded: "" });
expect(next.window).toEqual({ own: null, recorded: "" });
});
it("a window whose sizing couldn't be read is left as the broker set it", async () => {
it("a window whose sizing could not be read receives no untracked pin", async () => {
const { tmux, restoreWindowOption } = sizingTmux(undefined);
await visit(tmux);
expect(restoreWindowOption).not.toHaveBeenCalled();
});
it("without a window id the session's window is pinned as before, and nothing is given back to another window", async () => {
it("without a window id no untracked pin is made", async () => {
const { tmux, calls, restoreWindowOption } = sizingTmux("largest", { readWindow: async () => null });
await visit(tmux);
expect(calls).toEqual(["set:dev@rig:window-size=manual"]);
expect(calls).toEqual([]);
expect(restoreWindowOption).not.toHaveBeenCalled();
});
it("a pin record that cannot be written makes no manual override or resize", async () => {
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
const { tmux, window } = sizingTmux("largest", {
setWindowOption: async () => ({ ok: false, code: "unavailable", message: "busy" }), resizeWindow,
});
await visit(tmux);
expect(resizeWindow).not.toHaveBeenCalled();
expect(window).toEqual({ own: "largest", recorded: "" });
});
it("an explicit manual window without an OpenRig pin stays untouched by an opt-in browser", async () => {
const resizeWindow = vi.fn(async () => ({ ok: true as const }));
const { tmux, calls, window } = sizingTmux("manual", { resizeWindow });
await visit(tmux);
expect(resizeWindow).not.toHaveBeenCalled();
expect(calls).toEqual([]);
expect(window).toEqual({ own: "manual", recorded: "" });
});
it("a failed open gives the sizing back too", async () => {
const { tmux, window } = sizingTmux(null, {
startPipePane: async () => ({ ok: false, code: "session_not_found", message: "gone" }),
@@ -1124,13 +1151,13 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
it("a probe that couldn't reach tmux closes the viewers and still gives the sizing back, before eviction", async () => {
let probes = 0;
const { tmux, calls, window } = sizingTmux("largest", { hasSession: async () => probes++ === 0 });
const b = new TerminalSessionBroker("dev@rig", tmux, { pollMs: 10, livenessMs: 10, onEmpty: () => calls.push("empty") });
const b = new TerminalSessionBroker("dev@rig", tmux, { fixedGeometry: true, pollMs: 10, livenessMs: 10, onEmpty: () => calls.push("empty") });
const a = makeSub();
await b.attach(a);
await vi.waitFor(() => expect(calls.at(-1)).toBe("empty"));
expect(a.closed[0]?.code).toBe(1001);
expect(calls.slice(-3)).toEqual(["restore:@2:window-size=largest", "restore:@2:@openrig-window-size-prior=null", "empty"]);
expect(window).toEqual({ own: "largest", recorded: "" });
expect(calls.slice(-3)).toEqual(["restore:@2:window-size=null", "restore:@2:@openrig-window-size-prior=null", "empty"]);
expect(window).toEqual({ own: null, recorded: "" });
});
it("dispose gives the sizing back before the registry can create a new broker", async () => {
@@ -1181,6 +1208,6 @@ describe("TerminalSessionBroker - window sizing after the last viewer", () => {
await Promise.all([attached, disposed]);
expect(a.closed[0]?.code).toBe(1011);
expect(calls.at(-1)).toBe("empty");
expect(window).toEqual({ own: "largest", recorded: "" });
expect(window).toEqual({ own: null, recorded: "" });
});
});
@@ -0,0 +1,18 @@
import { execFile, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
it("the writable argv remains valid for Herdr restart reporting", () => {
const argv = tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false);
expect(argv.every(word => !/['\u0000-\u001f\u007f]/.test(word))).toBe(true);
});
it.skipIf(spawnSync("tmux", ["-V"]).status !== 0)("the shipped writable tile follows phone, laptop and every outer-terminal resize", async () => {
const { stdout } = await promisify(execFile)("python3", [
fileURLToPath(new URL("./helpers/tmux-tile-reflow.py", import.meta.url)),
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false)),
], { timeout: 45000 });
expect(JSON.parse(stdout)).toEqual({ sizes: ["40x24", "120x35", "160x45", "52x28", "132x40", "40x24", "132x40"], localPin: "unset", nativeInput: true, firstKeys: true, pasteBytes: 448012, prefixCopyMode: true });
}, 50000);
@@ -6,13 +6,6 @@ import { describe, expect, it } from "vitest";
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
const available = spawnSync("tmux", ["-V"]).status === 0 && spawnSync("python3", ["--version"]).status === 0;
// From tmux 3.5 a tile's first keys go through the attaching client, so a prefix typed first acts as the prefix. Below
// 3.5 they go to the pane, so Ctrl-b d typed first reaches the seat (two more bytes), and the prefix-then-PgUp case is
// skipped.
const tmuxVersion = String(spawnSync("tmux", ["-V"], { encoding: "utf8" }).stdout ?? "").trim().replace(/^tmux /, "");
const tmuxMinor = /^(\d+)\.(\d+)/.exec(tmuxVersion);
const throughClient = /^(next-|master$)/.test(tmuxVersion)
|| (tmuxMinor !== null && (Number(tmuxMinor[1]) > 3 || (Number(tmuxMinor[1]) === 3 && Number(tmuxMinor[2]) >= 5)));
// Linux's /bin/sh is often dash, whose background commands read /dev/null unless given another input.
const shells = ["/bin/sh", ...(existsSync("/bin/dash") ? ["/bin/dash"] : [])];
// The helper runs for many seconds; running it without blocking keeps the test worker able to report to vitest.
@@ -23,28 +16,11 @@ const helper = async (shell: string, ...more: string[]) => JSON.parse((await pro
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1, 2)),
...more,
], { encoding: "utf8", timeout: 150000 })).stdout);
// tmux 3.4 and later turn paste framing on only when a client starts, so a framing-off write between the first key and
// the attach leaves a paste in that gap unframed: each of its lines would reach the agent as Enter.
it("a writable tile turns paste framing off only on the return that doesn't attach", () => {
const script = tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1, 2).find((arg) => arg.includes("use()")) ?? "";
const use = script.slice(script.indexOf("use()"), script.indexOf("take()"));
expect(use.length).toBeGreaterThan(0);
expect(use.split("\\033[?2004l").length - 1).toBe(1);
expect(use).toContain(`then printf "\\033[?2004l"; stty sane 2>/dev/null;`);
});
describe.skipIf(!available)("a tile views its seat without sizing it, and attaches only while the person types", () => {
it.each(shells)("under %s: whole lines, the seat's own size, keys reaching the seat, and back to the view when idle", async (shell) => {
describe.skipIf(!available)("read-only tiles remain observers beside native sizing clients", () => {
it.each(shells)("under %s: whole lines without typing or sizing the seat", async (shell) => {
expect(await helper(shell)).toEqual({
readOnlyAlone: { window: "160x20" },
readOnlyBesideAnother: { window: "160x20" },
writable: { used: "40x30" },
firstInput: { bytes: throughClient ? 183 : 185 },
firstInputWindow: { bytes: 3 },
});
}, 180000);
it("a paste over 256 KiB arrives whole and framed, and the tile still detaches when idle after it", async () => {
expect(await helper("/bin/sh", "big-paste")).toEqual({ bigPaste: { bytes: 6 + 16000 * 28 + 6 } });
}, 180000);
}, 30000);
});
@@ -17,7 +17,7 @@ import type { ComposedPane, ComposedView, OpenViewResult, TerminalProvider } fro
const local = (seat: string, readOnly = false): ViewMemberInput => ({ seat, label: seat, tmuxSession: seat, host: null, readOnly, alive: true });
describe("#707 local tiles run the tmux the daemon resolves", () => {
it("a provider PATH without tmux fails the bare command; the resolved path views the seat, and neither mode attaches untyped", () => {
it("a provider PATH without tmux fails the bare command; the resolved path attaches writable tiles and observes read-only tiles", () => {
const root = mkdtempSync(join(tmpdir(), "openrig-707-"));
const narrow = join(root, "provider-path"); // the provider's PATH: system tools, no tmux
const elsewhere = join(root, "elsewhere"); // where the daemon found tmux
@@ -48,11 +48,15 @@ esac
const resolved = composeView("v", [local("seat'a", readOnly)], { resolveHost: () => null, localTmux: tmux });
rmSync(seen, { force: true });
rmSync(other, { force: true });
const out = execFileSync("/bin/sh", ["-c", resolved.opened[0]!.paneCommand], { env, encoding: "utf8", timeout: 10000 });
expect(out).toContain("SCREEN");
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["capture-pane", "-p", "-e", "-t", "session-id"]);
// Nobody typed, so nothing attached or typed into the seat.
expect(existsSync(other)).toBe(false);
if (readOnly) expect(out).toContain("SCREEN");
if (readOnly) {
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["capture-pane", "-p", "-e", "-t", "session-id"]);
expect(existsSync(other)).toBe(false);
} else {
expect(readFileSync(other, "utf8").trimEnd().split("\n")).toEqual(["attach", "-t", "session-id"]);
}
expect(resolved.opened[0]!.readOnly).toBe(readOnly);
}
} finally {
@@ -80,8 +84,8 @@ esac
const view = composeView("v", [local("seat", readOnly)], { resolveHost: () => null, localTmux: tmux });
const result = spawnSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], { env: { PATH: narrow }, encoding: "utf8", timeout: 10000 });
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
expect(result.stdout).toContain("SCREEN");
expect(result.status).toBe(readOnly ? 1 : 0);
if (readOnly) expect(result.stdout).toContain("SCREEN");
}
} finally {
rmSync(root, { recursive: true, force: true });
+14 -11
View File
@@ -346,12 +346,15 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
const startPipePaneCalls: string[] = [];
const resizeWindowCalls: Array<{ cols: number; rows: number }> = [];
let capturedOutputPath: string | null = null;
let fixedGeometry = false;
beforeAll(async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, {
hasSession: async () => true,
readWindow: async (name: string) => ({ id: name, userOption: "" }),
getWindowLocalOption: async () => null,
setWindowOption: async () => ({ ok: true }),
resizeWindow: async (_n: string, cols: number, rows: number) => {
resizeWindowCalls.push({ cols, rows });
@@ -371,7 +374,7 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
await next();
});
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: BROKER_TOKEN });
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: BROKER_TOKEN, fixedGeometry: () => fixedGeometry });
brokerServer = serve({ fetch: app.fetch, port: BROKER_PORT, hostname: "127.0.0.1" });
injectWebSocket(brokerServer);
await new Promise<void>((resolve) => setTimeout(resolve, 100));
@@ -415,21 +418,21 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
b.close();
}, 10000);
it("a client resize message never resizes the pane (FR-7): only the one canonical geometry call", async () => {
it("default browser traffic never pins the PTY; a newly opted-in broker uses fixed geometry", async () => {
resizeWindowCalls.length = 0;
const ws = await openWs("resize-session");
// One canonical-geometry resize happens at broker open.
await vi.waitFor(() => {
expect(resizeWindowCalls).toHaveLength(1);
}, { timeout: 1000 });
expect(resizeWindowCalls[0]).toEqual({ cols: 90, rows: 27 });
ws.send(JSON.stringify({ type: "resize", cols: 200, rows: 9 }));
// Give the message time to (not) be processed.
await new Promise<void>((r) => setTimeout(r, 120));
expect(resizeWindowCalls).toHaveLength(1); // unchanged - the resize was ignored
expect(resizeWindowCalls).toEqual([]);
ws.close();
fixedGeometry = true;
const opted = await openWs("fixed-session");
await vi.waitFor(() => expect(resizeWindowCalls).toEqual([{ cols: 90, rows: 27 }]));
opted.send(JSON.stringify({ type: "resize", cols: 200, rows: 9 }));
await new Promise<void>((r) => setTimeout(r, 120));
expect(resizeWindowCalls).toEqual([{ cols: 90, rows: 27 }]);
opted.close();
}, 10000);
});
@@ -116,6 +116,8 @@ describe("config routes (User Settings v0)", () => {
// + 1 output.whoami_peers (slice 15 P5 whoami peer bound; default 8).
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
// + 1 terminal.fixed_geometry (web pin opt-in; default off).
expect(body.settings["terminal.fixed_geometry"]).toMatchObject({ value: false, source: "default" });
// + 1 terminal.tmux_server (per-instance server selection; default inherit).
expect(body.settings["terminal.tmux_server"]).toMatchObject({ value: "inherit", source: "default" });
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
@@ -140,7 +142,7 @@ describe("config routes (User Settings v0)", () => {
// + 1 retention.events_days (the history-only event types of events; default 0 = keep).
// + 2 retention.closed_content_days, retention.closed_metadata_days (closed-work erasure; default 0 = keep).
// + 1 retention.erasure_ledger (queue erasure's replay ledger; default empty = none).
expect(Object.keys(body.settings).length).toBe(103);
expect(Object.keys(body.settings).length).toBe(104);
expect(body.settings["launch.codex_login_shell"]).toMatchObject({ value: false, source: "default", defaultValue: false });
expect(body.settings["transport.draft_delivery"]).toMatchObject({ value: "deliver", source: "default", defaultValue: "deliver" });
expect(body.settings["retention.events_days"]).toMatchObject({ value: 0, source: "default", defaultValue: 0 });
@@ -25,7 +25,7 @@ const DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX = "compaction/post-compact-extra.md"
function clearEnv(): () => void {
const keys = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_TERMINAL_FIXED_GEOMETRY", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
@@ -99,6 +99,15 @@ describe("SettingsStore (User Settings v0)", () => {
expect(() => store.set("terminal.tmux_server", "isolate")).toThrow('must be "inherit" or "isolated"');
});
it("web fixed geometry is off by default and explicitly selectable through file or environment", () => {
const store = new SettingsStore(configPath);
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: false, source: "default" });
store.set("terminal.fixed_geometry", "true");
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: true, source: "file" });
process.env.OPENRIG_TERMINAL_FIXED_GEOMETRY = "false";
expect(store.resolveOne("terminal.fixed_geometry")).toMatchObject({ value: false, source: "env" });
});
it("SETTINGS_VALID_KEYS matches the documented settings key set", () => {
expect([...SETTINGS_VALID_KEYS]).toEqual([
"daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms",
@@ -170,6 +179,7 @@ describe("SettingsStore (User Settings v0)", () => {
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
"output.compact",