fix(terminal): keep seat tiles through session replacement (#1964)

An open rig view keeps a seat’s tile when its tmux session is replaced. The shared command waits up to 30 seconds for the same session name and managed node identity, then reattaches by exact tmux ID. It checks the name returned by tmux before reading identity, so a missing target cannot select a sibling. Removed or absent identity markers keep one-shot attachment. Herdr reuse and TUI/CLI copy guidance read the separate short recovery command.

Cost: one shell per tile, with metadata polling only while disconnected. A deliberate detach takes about two seconds to close; a replacement delayed beyond that live-session grace can require reopening. Close and reopen views created before this change. No watcher, setting, schema or provider-specific recovery mechanism.

Validation: real private tmux reproductions cover four-seat replacement, removed identity, a missing target during replacement, and disappearance before opening; each fails before its corresponding fix. The last two exposed the CI failure without increasing any timeout. All 458 affected terminal contract and CLI window checks plus the daemon typecheck pass locally at this revision. Earlier round checks also covered TUI rendering and CLI/TUI typechecks; those were not repeated for this shell-only correction. Tests use the actual stop adapter/composer because the scenario runner has no terminal-view action. Native Herdr/cmux/Rex apps and SSH were not run.

Addresses #1939. Native Herdr, cmux and Rex and real SSH were not run, so the issue stays open for the reporter to confirm.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-24 22:23:22 -07:00
1 parent 43378f5c87
commit 5f28fdb606
20 files changed
+283 -60

No files matched your search

+14
View File
@@ -40,6 +40,20 @@ and slice views, pagination, absent seats and partial results all use the same
composer. Selecting a session changes where those tiles are placed. It does
not relaunch the seats' tmux sessions or move queue ownership.
## Replacing a seat's session
New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then reattach 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 the existing attachment.
A deliberate detach closes the tile after about two seconds. A different seat
under the old name is never 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.
## Desktop windows and previews
```sh
+5 -4
View File
@@ -10,7 +10,7 @@ import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
import { openRexView } from "./terminal-window-rex.js";
import type { OpenViewResult } from "./commands/terminal.js";
interface Pane { seat: string; label: string; paneCommand: string }
interface Pane { seat: string; label: string; paneCommand: string; recoveryCommand?: string }
interface HerdrTab { workspace_id: string; tab_id: string; label: string }
interface Preview {
planId: string;
@@ -233,7 +233,7 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
const label = pageLabel(index);
const matches = tabs.filter(tab => tab.workspace_id === existing.workspace_id && tab.label === label);
if (matches.length !== 1) return false;
const remaining = page.map(pane => pane.paneCommand);
const remaining = page.map(pane => pane.recoveryCommand ?? pane.paneCommand);
for (const pane of panes.filter(pane => pane.tab_id === matches[0]!.tab_id)) {
if (remaining.length === 0) break;
const result = JSON.parse(await deps.exec("/usr/bin/env", [...herdrArgs, "pane", "process-info", "--pane", pane.pane_id]));
@@ -359,7 +359,8 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
const directCommand = herdr && socket
? `${configPrefix}env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(socket)}${configEnv} ${shellQuote(herdr)}`
: `env -u TMUX ${composed.opened[0]!.paneCommand.replace(/^ssh /, "ssh -t ")}`;
manualCommand = directCommand;
manualCommand = herdr && socket ? directCommand
: `env -u TMUX ${(composed.opened[0]!.recoveryCommand ?? composed.opened[0]!.paneCommand).replace(/^ssh /, "ssh -t ")}`;
if (typeof launchWindow === "string") {
const headless = deps.env["SSH_CONNECTION"] || deps.env["SSH_CLIENT"] || deps.env["SSH_TTY"]
|| (deps.platform === "linux" && !deps.env["DISPLAY"] && !deps.env["WAYLAND_DISPLAY"]);
@@ -375,7 +376,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
// A CLI session would override the daemon's resolved socket in Herdr.
if (typeof launchWindow === "function") {
windowAttempted = true;
window = await launchWindow(configPrefix ? `/bin/sh -c ${shellQuote(manualCommand)}` : manualCommand);
window = await launchWindow(configPrefix ? `/bin/sh -c ${shellQuote(directCommand)}` : directCommand);
}
await measureWindow();
let alive = false;
@@ -2,6 +2,7 @@ import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { terminalCommand, type TerminalDeps } from "../src/commands/terminal.js";
import type { WindowDeps } from "../src/terminal-window.js";
import { composeView } from "../../daemon/src/domain/terminal/view-composer.js";
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js"),
@@ -15,7 +16,9 @@ interface Tab { workspace_id: string; tab_id: string; label: string }
const spaceLabels: Record<string, string> = { "openrig-kernel": "openrig kernel", "old-name": "switchboard" };
function fixture(tabs: Tab[] = [], id = "kernel", session?: string) {
const socketPath = session === undefined ? "/daemon home/herdr.sock" : `/daemon home/sessions/${session}/herdr.sock`;
const panes = ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat, paneCommand: `tmux attach -t 'fixture-${seat}'` }));
const panes = composeView(id, ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat,
tmuxSession: `fixture-${seat}`, host: null, readOnly: false, alive: true,
})), { resolveHost: () => null, localTmux: "/fixture/bin/tmux" }).opened;
const composed = { id, opened: panes, pages: [panes], absent: [] as Array<{ seat: string; host: string | null; reason: string }>, degraded: [] as Array<{ seat: string; host: string; reason: string }> };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("preview") ? {
planId, composed, ...(session !== undefined ? { session } : {}),
@@ -170,7 +173,7 @@ describe("reopening a Herdr view in a desktop window", () => {
it("confirms an absolute tmux command, quoted session alias and read-only attachment", async () => {
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
f.composed.opened[0]!.paneCommand = "'/fixture/bin/tmux' attach -r -t 'alias'\"'\"'s session'";
f.composed.opened[0]!.recoveryCommand = "'/fixture/bin/tmux' attach -r -t 'alias'\"'\"'s session'";
f.clients.mockResolvedValue("101\talias's session\t1\n102\tfixture-advisor\t0\n103\tfixture-operator\t0");
await f.run();
expect(f.focus).toHaveBeenCalledExactlyOnceWith("first");
@@ -190,7 +193,7 @@ describe("reopening a Herdr view in a desktop window", () => {
it("creates a fresh SSH view when local attachment inventory cannot prove it", async () => {
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
f.composed.opened[0]!.paneCommand = "ssh 'remote' 'tmux attach -t remote-seat'";
f.composed.opened[0]!.recoveryCommand = "ssh 'remote' 'tmux attach -t remote-seat'";
await f.run();
expect(f.focus).not.toHaveBeenCalled();
expect(f.post).toHaveBeenCalledTimes(1);
+8 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.js";
import { shellQuote } from "../src/cross-host-executor.js";
import { composeView } from "../../daemon/src/domain/terminal/view-composer.js";
function fixture(options: { herdr?: boolean; ghostty?: string; refusal?: number; empty?: boolean; alive?: boolean } = {}) {
const panes = ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat, paneCommand: `tmux attach-session -t '=fixture-${seat}'` }));
@@ -182,7 +183,10 @@ describe("desktop terminal view", () => {
const f = fixture({ herdr });
f.deps.env = {};
Object.assign(f.preview, { status: {} });
f.preview.composed.opened[0]!.paneCommand = "ssh 'user@remote-host' 'tmux attach-session -t =operator'";
Object.assign(f.preview.composed.opened[0]!, {
paneCommand: "ssh 'user@remote-host' 'long reconnect loop'",
recoveryCommand: "ssh 'user@remote-host' 'tmux attach-session -t =operator'",
});
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result).toMatchObject({ ok: false, windowAttempted: false });
expect(result.error).toContain("run: env -u TMUX ssh -t 'user@remote-host' 'tmux attach-session -t =operator'");
@@ -233,8 +237,11 @@ describe("desktop terminal view", () => {
it("opens the previewed tmux layout when the expected plan still matches", async () => {
const f = fixture({ herdr: false });
const tile = composeView("kernel", [{ seat: "tui", label: "tui", tmuxSession: "fixture-tui", host: null, readOnly: false, alive: true }], { resolveHost: () => null }).opened[0]!;
Object.assign(f.preview.composed.opened[0]!, tile);
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps, "bound-plan");
expect(result).toMatchObject({ ok: true, provider: "tmux", opened: ["tui", "advisor", "operator"] });
expect(f.exec.mock.calls.some(([file, args]) => file === "/fixture/bin/tmux" && args.includes(`env -u TMUX ${tile.paneCommand}`))).toBe(true);
});
it("opens a new Ghostty tab and applies the same daemon plan and Herdr endpoint", async () => {
@@ -1367,10 +1367,10 @@ export class SeatLifecycleService {
false,
);
if (!stopped.ok) return stopped;
// All tile providers attach once; the safe detach must not silently look
// like a seat disappearing. Report recovery without inventorying or reopening views.
// Older open views still attach once. Keep recovery guidance for those
// views and replacements that outlast the new tile command's wait.
const view = shellQuote(`rig:${seat.rigName}`);
viewWarnings.push(`Replacing the tmux session for "${canonicalSessionName}" detaches its open terminal tiles. Reopen the view: rig terminal open ${view} --window (Herdr or tmux), or rig terminal open ${view} --provider cmux.`);
viewWarnings.push(`Replacing the tmux session for "${canonicalSessionName}" detaches its open terminal clients. New tiles wait up to 30 seconds for the same seat; for older views or a tile that closes, close and reopen the view: rig terminal open ${view} --window (Herdr or tmux), or rig terminal open ${view} --provider cmux.`);
// Stopping the server's last session ends tmux's server, and every probe
// below would then be transport_unavailable, never absence. Restore an
// empty server (no session is invented; a no-op while the server is up)
@@ -29,6 +29,8 @@ export interface ComposedPane {
label: string;
/** Provider-neutral shell command; a provider may wrap it using runtime metadata. */
paneCommand: string;
/** Short, one-shot attach for a person's manual recovery instructions. */
recoveryCommand?: string;
/** The same local attach as an argv, for a provider that reruns it without a shell (a Herdr restart). Absent for an ssh tile. */
attachArgv?: string[];
/** True when the attach is view-only (`tmux attach -r`) — cross-rig / saved read-only. */
@@ -237,7 +237,7 @@ export class TerminalService {
const result = await provider.openView({ ...composed, planId });
if (result.opened.length === 0 && composed.opened.length > 0) {
notes.push("In a new terminal on the daemon's host, attach directly using one of these commands:");
for (const pane of composed.opened) notes.push(`${pane.label}: env -u TMUX ${pane.paneCommand}`);
for (const pane of composed.opened) notes.push(`${pane.label}: env -u TMUX ${pane.recoveryCommand ?? pane.paneCommand}`);
notes.push("Each labelled command above opens a different seat. When sharing how to join this team, include every command in full, unchanged, including env -u TMUX, paths and quoting.");
}
if (!composed.kernelLayout && composed.opened.some(pane => !pane.readOnly)) {
@@ -0,0 +1,44 @@
// A tile owns its attach process; no daemon watcher or steady-state polling.
// 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 tileAttachArgv(
tmux: string, server: readonly string[], session: string, readOnly: boolean,
retrySeconds = 30,
): string[] {
const ro = readOnly ? "-r " : "";
// 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 = [
'target=$1; shift;',
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || exec "$@" attach ${ro}-t "=$target";`,
// display-message may succeed with empty/fallback context for a missing target.
`if [ "\${snapshot#*:}" != "$target" ]; then exec "$@" attach ${ro}-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.";',
`exec "$@" attach ${ro}-t "\${snapshot#* }"; fi;`,
'while :; do',
`"$@" attach ${ro}-t "\${snapshot#* }";`,
`left=${retrySeconds}; same=0;`,
'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;',
'if [ "$next" != "$snapshot" ]; then snapshot=$next; break; fi;',
// The stop adapter detaches BEFORE killing. Give that kill a chance to
// finish; an ordinary Ctrl-b d leaves the original alive and ends the tile.
'same=$((same + 1)); if [ "$same" -ge 3 ]; then exit 0; fi;',
'else same=0; fi; else same=0; 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];
}
@@ -5,11 +5,11 @@
// partition rules live here, in one testable pure function, so no provider
// re-implements them:
//
// local live → `tmux attach -t <session>` (tmux = the daemon-resolved
// absolute path when the context carries one)
// local live → bounded, identity-pinned tmux reattach command
// (tmux = the daemon-resolved absolute executable)
// view-only / cross-rig (read-only)
// → `tmux attach -r -t <session>`
// ssh host → `ssh <dest> tmux attach [-r] -t <session>`
// → the same reattach command with read-only `-r`
// ssh host → `ssh <dest> <reattach command>`
// http host → NO pane; honest-degrade { seat, host, reason }
// dead / no session → absent[] (named, never silently dropped)
//
@@ -18,6 +18,7 @@
// (ssh vs http vs unknown) is resolved through the operator's read-only hosts
// registry via the injected `resolveHost`.
import { tileAttachArgv } from "./tile-attach.js";
import type { HostEntry } from "../hosts/hosts-registry-reader.js";
import type {
AbsentSeat,
@@ -166,12 +167,13 @@ export function composeView(
});
continue;
}
const remoteCommand = `tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`;
const remoteCommand = tileAttachArgv("tmux", [], m.tmuxSession, m.readOnly).map(shellQuote).join(" ");
opened.push({
seat: m.seat,
...(m.runtime ? { runtime: m.runtime } : {}),
label: m.label,
paneCommand: `ssh ${shellQuote(dest)} ${shellQuote(remoteCommand)}`,
recoveryCommand: `ssh ${shellQuote(dest)} ${shellQuote(`tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`)}`,
readOnly: m.readOnly,
});
continue;
@@ -194,12 +196,14 @@ export function composeView(
});
continue;
}
const attachArgv = tileAttachArgv(ctx.localTmux ?? "tmux", ctx.localTmuxServer ?? [], m.tmuxSession, m.readOnly);
opened.push({
seat: m.seat,
...(m.runtime ? { runtime: m.runtime } : {}),
label: m.label,
paneCommand: `${localTmux} attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`,
attachArgv: [ctx.localTmux ?? "tmux", ...(ctx.localTmuxServer ?? []), "attach", ...(m.readOnly ? ["-r"] : []), "-t", m.tmuxSession],
paneCommand: attachArgv.map(shellQuote).join(" "),
recoveryCommand: `${localTmux} attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`,
attachArgv,
readOnly: m.readOnly,
});
}
+14 -10
View File
@@ -1,3 +1,5 @@
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
// A Herdr server restart restores its layout but not the pane processes, so OpenRig's tiles came back
// as empty shells. After each page applies, OpenRig now gives Herdr each tile's reattach command as a
// session-only report under the runtime's own label, which Herdr reruns after a restart while its
@@ -78,7 +80,7 @@ describe("Herdr tiles carry their reattach command, so a Herdr restart brings th
expect(res.opened).toEqual(["dev-impl@r"]);
expect(reports()).toEqual([{
pane_id: "w1:p1", source: "openrig", agent: "claude",
resume_argv: ["env", "HERDR_AGENT=claude", "OPENRIG_TILE=w1:p1", "/usr/bin/tmux", "-L", "openrig", "attach", "-t", "dev-impl@r"],
resume_argv: ["env", "HERDR_AGENT=claude", "OPENRIG_TILE=w1:p1", ...tileAttachArgv("/usr/bin/tmux", ["-L", "openrig"], "dev-impl@r", false)],
}]);
// A held state report would replace Herdr's screen detection of the agent in the tile.
expect(requests.some((r) => r.method === "pane.report_agent")).toBe(false);
@@ -97,9 +99,9 @@ describe("Herdr tiles carry their reattach command, so a Herdr restart brings th
expect(res.opened).toEqual(["qa@r", "shell@r", "remote@r", "pi@r"]);
expect(reports()).toEqual([
{ pane_id: "w1:p1", source: "openrig", agent: "codex",
resume_argv: ["env", "HERDR_AGENT=codex", "OPENRIG_TILE=w1:p1", "/usr/bin/tmux", "-L", "openrig", "attach", "-r", "-t", "qa@r"] },
resume_argv: ["env", "HERDR_AGENT=codex", "OPENRIG_TILE=w1:p1", ...tileAttachArgv("/usr/bin/tmux", ["-L", "openrig"], "qa@r", true)] },
{ pane_id: "w1:p4", source: "openrig", agent: "pi",
resume_argv: ["env", "HERDR_AGENT=pi", "OPENRIG_TILE=w1:p4", "/usr/bin/tmux", "-L", "openrig", "attach", "-t", "pi@r"] },
resume_argv: ["env", "HERDR_AGENT=pi", "OPENRIG_TILE=w1:p4", ...tileAttachArgv("/usr/bin/tmux", ["-L", "openrig"], "pi@r", false)] },
]);
});
@@ -107,14 +109,14 @@ describe("Herdr tiles carry their reattach command, so a Herdr restart brings th
const { adapter, reports } = herdr();
const res = await adapter.openView(compose(member("seat'a@r", "claude-code"), member("b@r", "claude-code")));
expect(res.opened).toEqual(["seat'a@r", "b@r"]);
expect(reports().map((r) => (r["resume_argv"] as string[]).at(-1))).toEqual(["b@r"]);
expect(reports().map((r) => (r["resume_argv"] as string[])[7])).toEqual(["b@r"]);
});
it("a pane that exited right after opening gets no report; its siblings still do", async () => {
const { adapter, reports } = herdr({ liveLabels: (labels) => labels.filter((l) => l !== "b@r") });
const res = await adapter.openView(compose(member("a@r", "claude-code"), member("b@r", "claude-code"), member("c@r", "claude-code")));
expect(res.opened).toEqual(["a@r", "c@r"]);
expect(reports().map((r) => [r["pane_id"], (r["resume_argv"] as string[]).at(-1)])).toEqual([["w1:p1", "a@r"], ["w1:p3", "c@r"]]);
expect(reports().map((r) => [r["pane_id"], (r["resume_argv"] as string[])[7]])).toEqual([["w1:p1", "a@r"], ["w1:p3", "c@r"]]);
});
it("a report Herdr refuses for another reason isn't retried: the seat stays opened and a note says to reopen after a restart", async () => {
@@ -180,10 +182,12 @@ describe("Herdr tiles carry their reattach command, so a Herdr restart brings th
describe("the composer gives a local tile the same attach as an argv", () => {
it("local tiles carry the attach argv beside the shell command; ssh tiles don't", () => {
const v = compose(member("a@r", "claude-code"), member("b@r", "codex", { readOnly: true }), member("s@r", "claude-code", { host: "vm1" }));
expect(v.opened.map((p) => [p.paneCommand, p.attachArgv])).toEqual([
["'/usr/bin/tmux' -L 'openrig' attach -t 'a@r'", ["/usr/bin/tmux", "-L", "openrig", "attach", "-t", "a@r"]],
["'/usr/bin/tmux' -L 'openrig' attach -r -t 'b@r'", ["/usr/bin/tmux", "-L", "openrig", "attach", "-r", "-t", "b@r"]],
["ssh '[email protected]' 'tmux attach -t '\"'\"'s@r'\"'\"''", undefined],
]);
for (const [index, seat] of ["a@r", "b@r"].entries()) {
const argv = tileAttachArgv("/usr/bin/tmux", ["-L", "openrig"], seat, index === 1);
expect(v.opened[index]!.attachArgv).toEqual(argv);
expect(v.opened[index]!.paneCommand).toBe(argv.map(shellQuote).join(" "));
}
expect(v.opened[2]!.attachArgv).toBeUndefined();
expect(v.opened[2]!.paneCommand).toContain("ssh '[email protected]'");
});
});
@@ -84,11 +84,14 @@ function service(herdr: TerminalProvider, cmux = new RecordingCmux()): { svc: Te
return { svc: new TerminalService(deps), cmux };
}
const cellSeats = (root: unknown): string[] => {
const cellSeats = (root: unknown, composed: ComposedView): string[] => {
const out: string[] = [];
const walk = (n: any) => {
if (!n || typeof n !== "object") return;
if (n.type === "pane" && Array.isArray(n.command)) { const m = /attach -t '([^']+)'/.exec(n.command.join(" ")); if (m) out.push(m[1]!); }
if (n.type === "pane" && Array.isArray(n.command)) {
const pane = composed.opened.find(pane => pane.paneCommand === n.command[2]);
if (pane) out.push(pane.seat);
}
for (const k of ["first", "second", "children", "left", "right", "top", "bottom"]) {
const v = n[k]; if (Array.isArray(v)) v.forEach(walk); else walk(v);
}
@@ -110,8 +113,8 @@ describe("S08 — the rig opens as one Herdr space, 16 cells per tab", () => {
expect(create.params).toEqual({ focus: false, label: RIG });
const applies = requests.filter((r) => r.method === "layout.apply");
expect(applies.map((a) => a.params["tab_label"])).toEqual([`openrig:rig:${RIG}#${preview.planId.slice(0, 16)}#tok/1`, `openrig:rig:${RIG}#${preview.planId.slice(0, 16)}#tok/2`]);
expect(applies.map((a) => cellSeats(a.params["root"]).length)).toEqual([16, 1]);
expect([...cellSeats(applies[0]!.params["root"]), ...cellSeats(applies[1]!.params["root"])]).toEqual(rows.map((r) => r.canonicalSessionName));
expect(applies.map((a) => cellSeats(a.params["root"], preview.composed).length)).toEqual([16, 1]);
expect([...cellSeats(applies[0]!.params["root"], preview.composed), ...cellSeats(applies[1]!.params["root"], preview.composed)]).toEqual(rows.map((r) => r.canonicalSessionName));
});
it("the first tab is a 4×4 equal grid; the preview plan pages the same way", async () => {
@@ -158,7 +158,7 @@ describe("SeatLifecycleService.launchFresh", () => {
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "replace occupant" });
expect(result.ok).toBe(!fail);
expect(result.warnings).toEqual(expect.arrayContaining([
expect.stringContaining(`Replacing the tmux session for "${seat.sessionName}" detaches its open terminal tiles.`),
expect.stringContaining(`Replacing the tmux session for "${seat.sessionName}" detaches its open terminal clients.`),
]));
expect(result.warnings?.join("\n")).toContain("rig terminal open 'rig:fresh-rig' --window");
expect(result.warnings?.join("\n")).toContain("--provider cmux");
@@ -167,7 +167,7 @@ describe("SeatLifecycleService.launchFresh", () => {
it("#1939: a clean seat launch does not claim that it replaced an open tile", async () => {
const seat = seedSeat({ clean: true });
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "first occupant" });
expect(result.warnings?.some(warning => warning.includes("open terminal tiles"))).not.toBe(true);
expect(result.warnings?.some(warning => warning.includes("open terminal clients"))).not.toBe(true);
});
it.each([true, false])("#729: retains warnings independently of fresh identity (verified=%s)", async verified => {
@@ -179,7 +179,7 @@ describe("SeatLifecycleService.launchFresh", () => {
]), seat.node.id);
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "explicit fresh" });
expect(result.ok).toBe(verified);
expect(result.warnings).toEqual(["Existing projection warning", expect.stringContaining("Startup submission unverified"), expect.stringContaining("open terminal tiles")]);
expect(result.warnings).toEqual(["Existing projection warning", expect.stringContaining("Startup submission unverified"), expect.stringContaining("open terminal clients")]);
if (verified) expect(new SeatIdentityStore(db).getForNode(seat.node.id)?.verdict).toBe("verified");
else expect(result).toMatchObject({ code: "runtime_identity_unverified", status: "attention_required" });
});
@@ -1,3 +1,5 @@
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
// OPR.0.4.6.02 C2 — the terminal-provider ride core commit.
//
// Covers the plan's C2 test contract:
@@ -80,7 +82,7 @@ describe("view-composer partition vectors", () => {
it("local live seat → tmux attach -t (no -r)", () => {
const v = composeView("v", [member({ seat: "dev-a@rig" })], ctxWith([]));
expect(v.opened).toHaveLength(1);
expect(v.opened[0]!.paneCommand).toBe("tmux attach -t 'dev-a@rig'");
expect(v.opened[0]!.paneCommand).toBe(tileAttachArgv("tmux", [], "dev-a@rig", false).map(shellQuote).join(" "));
expect(v.opened[0]!.readOnly).toBe(false);
expect(v.absent).toHaveLength(0);
expect(v.degraded).toHaveLength(0);
@@ -88,19 +90,19 @@ describe("view-composer partition vectors", () => {
it("view-only / cross-rig read-only seat → tmux attach -r -t", () => {
const v = composeView("v", [member({ seat: "dev-a@rig", readOnly: true })], ctxWith([]));
expect(v.opened[0]!.paneCommand).toBe("tmux attach -r -t 'dev-a@rig'");
expect(v.opened[0]!.paneCommand).toBe(tileAttachArgv("tmux", [], "dev-a@rig", true).map(shellQuote).join(" "));
expect(v.opened[0]!.readOnly).toBe(true);
});
it("ssh host → ssh '<user@target>' tmux attach -t (destination shell-quoted); read-only adds -r", () => {
const rw = composeView("v", [member({ seat: "s@r", host: "vm1", tmuxSession: "s@r" })], ctxWith([SSH_HOST]));
expect(rw.opened[0]!.paneCommand).toBe("ssh '[email protected]' 'tmux attach -t '\"'\"'s@r'\"'\"''");
expect(rw.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
const ro = composeView("v", [member({ seat: "s@r", host: "vm1", readOnly: true })], ctxWith([SSH_HOST]));
expect(ro.opened[0]!.paneCommand).toBe("ssh '[email protected]' 'tmux attach -r -t '\"'\"'s@r'\"'\"''");
expect(ro.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", true).map(shellQuote).join(" "))}`);
const nouser = composeView("v", [member({ seat: "s@r", host: "vm2" })], ctxWith([SSH_HOST_NO_USER]));
expect(nouser.opened[0]!.paneCommand).toBe("ssh '10.0.0.9' 'tmux attach -t '\"'\"'s@r'\"'\"''");
expect(nouser.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('10.0.0.9')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
});
// Guard G1 — the ssh destination is STRUCTURED registry data injected into a
@@ -111,7 +113,7 @@ describe("view-composer partition vectors", () => {
const v = composeView("v", [member({ seat: "s@r", host: "evil", tmuxSession: "s@r" })], ctxWith([NASTY]));
expect(v.opened).toHaveLength(1);
// single-quoted, embedded quote POSIX-escaped ('\'') — metacharacters are literal.
expect(v.opened[0]!.paneCommand).toBe("ssh 'u'\"'\"'x@a b; rm -rf /' 'tmux attach -t '\"'\"'s@r'\"'\"''");
expect(v.opened[0]!.paneCommand).toBe(`ssh ${shellQuote("u'x@a b; rm -rf /")} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
// the dangerous run never becomes its own shell word:
expect(v.opened[0]!.paneCommand).not.toContain("; rm -rf / tmux");
});
@@ -210,11 +212,11 @@ describe("view-composer partition vectors", () => {
collect(grid);
expect(panes.filter((pane) => pane.label).map((pane) => pane.command)).toEqual([
["sh", "-c", "env HERDR_AGENT=claude tmux attach -t 'claude@rig'"],
["sh", "-c", "env HERDR_AGENT=codex tmux attach -t 'codex@rig'"],
["sh", "-c", "env HERDR_AGENT=pi tmux attach -t 'pi@rig'"],
["sh", "-c", "tmux attach -t 'operator@rig'"],
["sh", "-c", "tmux attach -t 'unknown@rig'"],
["sh", "-c", `env HERDR_AGENT=claude ${tileAttachArgv("tmux", [], "claude@rig", false).map(shellQuote).join(" ")}`],
["sh", "-c", `env HERDR_AGENT=codex ${tileAttachArgv("tmux", [], "codex@rig", false).map(shellQuote).join(" ")}`],
["sh", "-c", `env HERDR_AGENT=pi ${tileAttachArgv("tmux", [], "pi@rig", false).map(shellQuote).join(" ")}`],
["sh", "-c", tileAttachArgv("tmux", [], "operator@rig", false).map(shellQuote).join(" ")],
["sh", "-c", tileAttachArgv("tmux", [], "unknown@rig", false).map(shellQuote).join(" ")],
]);
expect(panes.at(-1)?.command).toEqual(["sh"]);
});
@@ -12,7 +12,7 @@ it("keeps remote session names intact through both POSIX shell boundaries", () =
// Simulate ssh's remote-command reconstruction. The local and remote shell
// parsing are real; no SSH server or terminal provider is claimed here.
writeFileSync(join(bin, "ssh"), '#!/bin/sh\nshift\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
writeFileSync(join(bin, "tmux"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$OPR_ARGS"\n', { mode: 0o700 });
writeFileSync(join(bin, "tmux"), '#!/bin/sh\ncase $1 in display-message) printf "%s\\n" "$4" >> "$OPR_ARGS"; target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; show-environment) exit 1;; attach) printf "%s\\n" "$@" >> "$OPR_ARGS";; esac\n', { mode: 0o700 });
const argsFile = join(root, "args");
try {
for (const session of ["seat-normal", "seat space", "seat'quote"]) {
@@ -20,11 +20,12 @@ it("keeps remote session names intact through both POSIX shell boundaries", () =
const view = composeView("fixture", [{
seat: session, label: session, tmuxSession: session, host: "edge", readOnly, alive: true,
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }) });
writeFileSync(argsFile, "");
execFileSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], {
env: { PATH: `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile },
});
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual([
"attach", ...(readOnly ? ["-r"] : []), "-t", session,
`=${session}:`, "attach", ...(readOnly ? ["-r"] : []), "-t", "session-id",
]);
}
}
@@ -0,0 +1,136 @@
// Real private tmux servers and terminal seats; no provider apps or model calls.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { composeView } from "../src/domain/terminal/view-composer.js";
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { TmuxAdapter } from "../src/adapters/tmux.js";
const exec = promisify(execFile);
const until = async (predicate: () => Promise<boolean>) => {
for (let i = 0; i < 100; i++) {
if (await predicate()) return;
await new Promise(r => setTimeout(r, 40));
}
throw new Error("Timed out waiting for the tile's observed state");
};
async function fixture(run: (f: {
socket: string; seats: (args: string[]) => Promise<string>;
viewer: (args: string[]) => Promise<string>; adapter: TmuxAdapter;
}) => Promise<void>) {
const root = mkdtempSync(join(tmpdir(), "tile-follow-")), socket = join(root, "seats");
const env = { ...process.env, TERM: "xterm-256color" };
delete env.TMUX; delete env.TMUX_TMPDIR;
const tmux = async (sock: string, args: string[]) => (await exec("tmux", ["-S", sock, ...args], { env })).stdout.trim();
const seats = (args: string[]) => tmux(socket, args), viewer = (args: string[]) => tmux(join(root, "viewer"), args);
const adapter = new TmuxAdapter(async () => { throw new Error("argv only"); }, undefined, args => seats(args.slice(1)));
try {
await seats(["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 60"]);
// Even a user's permissive global option must not switch a tile to another seat (#187).
await seats(["set-option", "-g", "detach-on-destroy", "off"]);
await viewer(["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 60"]);
await viewer(["set-option", "-g", "-w", "remain-on-exit", "on"]);
try { await run({ socket, seats, viewer, adapter }); }
catch (error) {
console.log("TILE_DIAG", await seats(["list-sessions", "-F", "#{pid} #{session_id} #{session_name} #{session_attached}"]));
for (const pane of (await viewer(["list-panes", "-t", "=view:", "-F", "#{pane_id}"])).split("\n")) console.log(pane, await viewer(["capture-pane", "-p", "-S", "-100", "-t", pane]));
throw error;
}
} finally {
await viewer(["kill-server"]).catch(() => {});
await seats(["kill-server"]).catch(() => {});
rmSync(root, { recursive: true, force: true });
}
}
const terminal = (command: string) => `env -u TMUX sh -c ${shellQuote(command)}`;
it.skipIf(process.platform === "win32")("keeps all four terminal tiles on their seats after a fresh session replaces one", async () => {
await fixture(async ({ socket, seats, viewer, adapter }) => {
const names = Array.from({ length: 4 }, (_, i) => `dev${i}@demo`);
const create = (i: number, text = `SEAT${i}`) => seats(["new-session", "-d", "-s", names[i]!, "-e", `OPENRIG_NODE_ID=node${i}`, `printf '%s\\n' ${text}; sleep 60`]);
for (let i = 0; i < 4; i++) await create(i);
const view = composeView("demo", names.map((seat, i) => ({ seat, label: seat, tmuxSession: seat, host: null, readOnly: i === 3, alive: true })), {
resolveHost: () => null, localTmuxServer: ["-S", socket],
});
const panes: string[] = [];
for (const [i, tile] of view.opened.entries()) {
panes.push(await viewer(i === 0 ? ["new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "view", terminal(tile.paneCommand)] : ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", "=view:", terminal(tile.paneCommand)]));
await viewer(["select-layout", "-t", "=view:", "tiled"]);
}
const attached = () => seats(["list-sessions", "-F", "#{session_name}:#{session_attached}"]);
const expected = ["anchor:0", ...names.map(name => `${name}:1`)].sort();
await until(async () => JSON.stringify((await attached()).split("\n").sort()) === JSON.stringify(expected));
expect(await seats(["list-clients", "-t", `=${names[3]}`, "-F", "#{client_readonly}"])).toBe("1");
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
await create(1, "REPLACED_SEAT1");
await until(async () => JSON.stringify((await attached()).split("\n").sort()) === JSON.stringify(expected));
expect((await viewer(["list-panes", "-t", "=view:", "-F", "#{pane_dead}"])).split("\n")).toEqual(["0", "0", "0", "0"]);
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[1]!])).includes("REPLACED_SEAT1"));
// Current handover/restart respawn in place; the same tile stays attached.
await seats(["respawn-pane", "-k", "-t", `=${names[0]}:`, "printf 'RESPAWNED_SEAT0\\n'; sleep 60"]);
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[0]!])).includes("RESPAWNED_SEAT0"));
expect((await attached()).split("\n").sort()).toEqual(expected);
// A fresh launch of the last session restarts the server and can reuse $0.
for (const i of [0, 2, 3]) expect((await adapter.killSession(names[i]!)).ok).toBe(true);
await seats(["kill-session", "-t", "=anchor"]);
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
await create(1, "NEW_SERVER_SEAT1");
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[1]!])).includes("NEW_SERVER_SEAT1"));
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe(names[1]);
});
});
it.skipIf(process.platform === "win32").each(["detach", "identity", "removed", "unmanaged", "removed-marker"])("ends the tile on %s without attaching a sibling", async mode => {
await fixture(async ({ socket, seats, viewer, adapter }) => {
await seats(["new-session", "-d", "-s", "seat", ...(mode === "unmanaged" ? [] : ["-e", "OPENRIG_NODE_ID=original"]), "sleep 60"]);
if (mode === "removed-marker") await seats(["set-environment", "-r", "-t", "=seat", "OPENRIG_NODE_ID"]);
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, mode === "detach" ? 4 : 2).map(shellQuote).join(" ");
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
await until(async () => (await seats(["list-clients", "-t", "=seat", "-F", "#{session_name}"])) === "seat");
if (mode === "detach") await seats(["detach-client", "-s", "=seat"]);
else {
expect((await adapter.killSession("seat")).ok).toBe(true);
const name = mode === "removed" ? "seat-prefix" : "seat";
await seats(["new-session", "-d", "-s", name, "-e", "OPENRIG_NODE_ID=other-seat", "sleep 60",
...(mode === "removed-marker" ? [";", "set-environment", "-r", "-t", "=seat", "OPENRIG_NODE_ID"] : [])]);
}
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe("");
if (mode === "detach") expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("No replacement");
});
});
it.skipIf(process.platform === "win32")("waits through missing-target observations without reading a sibling's identity", async () => {
await fixture(async ({ socket, seats, viewer, adapter }) => {
await seats(["set-environment", "-t", "=anchor", "OPENRIG_NODE_ID", "sibling"]);
const create = () => seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'REPLACEMENT\\n'; sleep 60"]);
await create();
const wrapper = `${socket}-probe`, missing = `${socket}-missing`;
// Observe actual tmux absence; don't create the replacement until two retry probes saw it.
writeFileSync(wrapper, `#!/bin/sh
if [ "$3" = display-message ] && ! tmux -S ${shellQuote(socket)} has-session -t =seat 2>/dev/null; then
printf '%s\\n' missing >> ${shellQuote(missing)}
fi
exec tmux "$@"
`, { mode: 0o700 });
const command = tileAttachArgv(wrapper, ["-S", socket], "seat", false).map(shellQuote).join(" ");
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
await until(async () => (await seats(["list-clients", "-t", "=seat", "-F", "#{session_name}"])) === "seat");
expect((await adapter.killSession("seat")).ok).toBe(true);
await until(async () => existsSync(missing) && readFileSync(missing, "utf8").trim().split("\n").length >= 2);
await create();
await until(async () => (await seats(["list-clients", "-F", "#{session_name}"])) === "seat");
expect(await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])).toBe("0");
});
});
it.skipIf(process.platform === "win32")("does not attach a sibling when the requested session disappeared before opening", async () => {
await fixture(async ({ socket, seats, viewer }) => {
await seats(["set-environment", "-t", "=anchor", "OPENRIG_NODE_ID", "sibling"]);
const command = tileAttachArgv("tmux", ["-S", socket], "missing", false).map(shellQuote).join(" ");
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe("");
});
});
@@ -132,7 +132,7 @@ describe("TerminalService — view resolution + one-shape result", () => {
expect(res.opened).toEqual(["dev-driver@acme-build", "rev-r1@acme-build"]);
// interactive → no `-r` in the composed pane commands
expect(herdr.lastView?.opened.every((p) => p.readOnly === false)).toBe(true);
expect(herdr.lastView?.opened[0]?.paneCommand).toBe("tmux attach -t 'dev-driver@acme-build'");
expect(herdr.lastView?.opened[0]?.paneCommand).toContain("'dev-driver@acme-build'");
});
it("the rig:<id> alias form resolves the same rig (the rig-scoped route delegation)", async () => {
@@ -1,3 +1,5 @@
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
// #707 — a seat's tile must not depend on the terminal provider's PATH to find tmux, and a pane that
// exits at once must not be reported as opened. The shell parsing below is real; no terminal
// provider, tmux server or ssh server is claimed, and the herdr replies are fakes.
@@ -22,7 +24,7 @@ describe("#707 local tiles run the tmux the daemon resolves", () => {
mkdirSync(narrow);
mkdirSync(elsewhere);
const tmux = join(elsewhere, "tmux");
writeFileSync(tmux, '#!/bin/sh\nprintf "%s\\n" "$@" > "$OPR_ARGS"\n', { mode: 0o700 });
writeFileSync(tmux, '#!/bin/sh\ncase $1 in display-message) target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; show-environment) exit 1;; attach) printf "%s\\n" "$@" > "$OPR_ARGS";; esac\n', { mode: 0o700 });
const argsFile = join(root, "args");
const env = { PATH: narrow, OPR_ARGS: argsFile };
try {
@@ -33,7 +35,7 @@ describe("#707 local tiles run the tmux the daemon resolves", () => {
const resolved = composeView("v", [local("seat'a", readOnly)], { resolveHost: () => null, localTmux: tmux });
execFileSync("/bin/sh", ["-c", resolved.opened[0]!.paneCommand], { env });
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["attach", ...(readOnly ? ["-r"] : []), "-t", "seat'a"]);
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["attach", ...(readOnly ? ["-r"] : []), "-t", "session-id"]);
expect(resolved.opened[0]!.readOnly).toBe(readOnly);
}
} finally {
@@ -54,7 +56,7 @@ describe("#707 local tiles run the tmux the daemon resolves", () => {
const members = [local("a"), local("b", true)];
const before = composeView("v", members, { resolveHost: () => null });
expect(JSON.stringify(composeView("v", members, { resolveHost: () => null, localTmux: undefined }))).toBe(JSON.stringify(before));
expect(before.opened.map((p) => p.paneCommand)).toEqual(["tmux attach -t 'a'", "tmux attach -r -t 'b'"]);
expect(before.opened.map((p) => p.paneCommand)).toEqual([tileAttachArgv("tmux", [], "a", false).map(shellQuote).join(" "), tileAttachArgv("tmux", [], "b", true).map(shellQuote).join(" ")]);
});
});
@@ -99,7 +101,7 @@ describe("#707 the service passes only an absolute resolved tmux to the composer
const { svc, herdr } = service(resolver);
const res = await svc.openView({ view: "rig:r" });
expect(res.ok).toBe(true);
expect(herdr.last!.opened[0]!.paneCommand).toBe(expected);
expect(herdr.last!.opened[0]!.recoveryCommand).toBe(expected);
});
it("resolves once, so the preview's plan still matches the open", async () => {
+3 -3
View File
@@ -59,14 +59,14 @@ describe("the daemon's tmux server", () => {
const remote: ViewMemberInput = { seat: "dev-build@starter", label: "builder", tmuxSession: "dev-build@starter", host: "box", readOnly: false, alive: true };
const resolveHost = () => ({ id: "box", transport: "ssh" as const, target: "box.example" });
const plain = composeView("v", [local, remote], { resolveHost });
expect(plain.opened.map((p) => p.paneCommand)).toEqual([
expect(plain.opened.map((p) => p.recoveryCommand)).toEqual([
"tmux attach -t 'dev-review@starter'",
"ssh 'box.example' 'tmux attach -t '\"'\"'dev-build@starter'\"'\"''",
]);
const onServer = composeView("v", [local, remote], { resolveHost, localTmux: "/usr/bin/tmux", localTmuxServer: ["-L", "person"] });
expect(onServer.opened.map((p) => p.paneCommand)).toEqual([
expect(onServer.opened.map((p) => p.recoveryCommand)).toEqual([
"'/usr/bin/tmux' -L 'person' attach -t 'dev-review@starter'",
plain.opened[1]!.paneCommand,
plain.opened[1]!.recoveryCommand,
]);
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ export interface TerminalPreview {
status: { available: boolean };
composed: {
id: string;
pages: Array<Array<{ seat: string; label: string; readOnly: boolean; paneCommand: string }>>;
pages: Array<Array<{ seat: string; label: string; readOnly: boolean; paneCommand: string; recoveryCommand?: string }>>;
opened: Array<{ seat: string }>;
absent: Array<{ seat: string; reason: string }>;
degraded: Array<{ seat: string; reason: string }>;
@@ -142,7 +142,7 @@ export function terminalLines(state: ViewState, snap: FleetSnapshot, width: numb
lines.push({ text: `Headless or remote: open a NEW terminal/tab on the machine running the selected daemon (${read.daemonTarget ?? "address unreported"}), then attach with one command below per terminal. Existing conversations stay in place.` });
lines.push({ text: "If a command wraps, widen this terminal until it fits on one line before copying." });
for (const member of page) {
const command = member.paneCommand.replace(/^ssh /, "ssh -t ");
const command = (member.recoveryCommand ?? member.paneCommand).replace(/^ssh /, "ssh -t ");
lines.push({ text: `${member.label} · ${member.seat}` }, { text: `env -u TMUX ${command}` });
}
}
+1 -1
View File
@@ -139,7 +139,7 @@ describe("terminal browser → preview → explicit Open", () => {
expect(text).toContain("saved:remote view");
expect(text).toContain("selected daemon's desktop");
expect(text).toContain("Headless or remote");
const command = snap.terminals!.preview!.composed.pages[0]![0]!.paneCommand;
const command = snap.terminals!.preview!.composed.pages[0]![0]!.recoveryCommand!;
expect(text).toContain(`env -u TMUX ${command.replace(/^ssh /, "ssh -t ")}`);
expect(text).toContain("[email protected]");
expect(text).toContain("seat with spaces");