mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7dc9720c | ||
|
|
be1b910a4b |
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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)
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user