Compare commits

...
16 changed files with 380 additions and 119 deletions

No files matched your search

+3 -2
View File
@@ -67,8 +67,9 @@ rig daemon start # if stopped
rig terminal open saved:kernel --window
```
`--window` opens a new terminal tab or window itself, with the **operator** filling
the first page. The dashboard and advisor each have their own tab or tmux window.
`--window` opens a new terminal tab or window itself. Below 120 measured columns,
the **operator** fills the first page; from 120, the dashboard and operator share
it equally. The advisor has a separate tab or tmux window.
An installing agent can run it from its shell on the daemon's
desktop; the person copies nothing. It uses Herdr when installed, otherwise
the same layout in plain tmux, and preserves existing conversations. `rig tui`
+24 -16
View File
@@ -186,8 +186,9 @@ when it starts the daemon.
## Open the kernel conversations
This is the **welcome screen** or **OpenRig view**: the operator in one full-width pane, in a new terminal window or tab.
The dashboard and advisor each have a separate tab or tmux window.
This is the **welcome screen** or **OpenRig view**, in a new terminal window or tab.
Below 120 measured columns, the operator fills the first page; from 120, the dashboard and operator share it equally.
The advisor always has a separate tab or tmux window.
**Agents:** the `rigs` skill says when to open it, what to check, what to do when it can't open, and how to hand the
person's goal to the operator; [Install and sign in](#install-and-sign-in) says how to load it.
@@ -221,12 +222,11 @@ xterm). Linux reports a window request; verify what actually appeared. SSH,
headless, CI and unsupported hosts get a no-window result with the reason,
a command for the person to run, and any follow-up the agent needs to perform.
OpenRig's Herdr launch and its printed manual command use a private copy of
your Herdr settings with only the initial sidebar preference changed to
collapsed. An endpoint's saved choice takes precedence; later sidebar toggles
persist normally. Your global config is untouched. If that copy cannot be
prepared, OpenRig reports why and keeps the ordinary launch or manual command
with its usual settings and sidebar.
OpenRig's Herdr launch and its printed manual command keep your personal Herdr
config when present. If it is absent, OpenRig creates private sidebar defaults;
the new desktop terminal selects them from its measured width. The manual command
uses the collapsed default. If private settings cannot be prepared, OpenRig
reports why and keeps the ordinary launch or manual command with Herdr's usual settings.
The command runs Herdr when installed, using the daemon's configured session and
socket. Otherwise it creates a plain tmux viewing session. Explicitly choose the
@@ -243,20 +243,28 @@ to close them after inspection; nothing is closed automatically. If the workspac
inventory cannot be read, the command creates a fresh workspace and reports the
inventory problem.
Terminal.app copies the person's front-window bounds to the new view without
changing the original. Ghostty keeps the current window size when adding a tab.
OpenRig keeps an existing personal Herdr config unchanged. Only when that config
is absent, its private default starts the sidebar open at 160 columns or more,
and collapsed below that width. Unknown widths use the collapsed default.
### Confirm the view
The default `saved:kernel` composition opens **the operator** alone on the first
page, for Claude-only, Codex-only and mixed kernels. With all three roles ready,
the dashboard is on the second page and the advisor on the third: select their
Herdr tab to switch. In plain tmux, press **Ctrl-b, then w** and choose `view-2` for the dashboard or `view-3`
for the advisor; `view-1` returns to the operator. The queue worker remains
For Claude-only, Codex-only and mixed kernels, the default `saved:kernel` layout
uses the viewing terminal's measured width. Below 120 columns (or when width is
unknown), **the operator** fills the first page, followed by dashboard and advisor
pages. From 120 columns, dashboard and operator share the first page equally, with
the advisor on a separate page. Select a Herdr tab to switch; in plain tmux, press
**Ctrl-b, then w** and choose a `view-*` window; `view-1` is the welcome page.
Missing roles are reported rather than filled with another conversation. The queue worker remains
reachable through the dashboard. The composition uses the installed kernel's current
bindings; no YAML edit, seat launch or daemon restart is needed. A custom saved
view named `kernel` still takes precedence.
Inspect `opened`, `absent`, `degraded`, `window` and any notes. Confirm the new
surface visibly shows the operator, with the dashboard and advisor reachable on
their own pages, and that the original terminal remains intact. The person sees Herdr only in a terminal they can see.
surface visibly shows the operator, with the dashboard and advisor reachable,
and that the original terminal remains intact. The person sees Herdr only in a terminal they can see.
Creating its workspace or switching the shared TUI to `:terminals` does not open
that terminal. A created window or successful CLI response alone is not visual
confirmation. A partial view remains partial. If the shared TUI tile shows a
@@ -326,7 +334,7 @@ is separate from the first-install desktop action above.
### Plain terminal: a new viewing session
`rig terminal open saved:kernel --provider tmux --window --json` creates and opens
the operator page first, with separate dashboard and advisor windows. It reuses
the same width-based welcome layout, with the advisor in a separate window. It reuses
the daemon's composition and preserves existing sessions. The result names the viewing session. No pane names or shell
commands need to be assembled by the person.
+7 -14
View File
@@ -2,25 +2,18 @@ import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { parse, stringify } from "smol-toml";
/** Herdr 0.9.3 config precedence; a sibling copy preserves config-relative paths. */
export function prepareHerdrLaunchConfig(env: NodeJS.ProcessEnv, socketPath: string): string {
/** Herdr config precedence: personal files win; only an absent config gets a private default. */
export function prepareHerdrLaunchConfig(env: NodeJS.ProcessEnv, socketPath: string, columns?: number): string {
const source = path.resolve(env["HERDR_CONFIG_PATH"] ?? path.join(
env["XDG_CONFIG_HOME"] ?? path.join(env["HOME"] ?? homedir(), ".config"), "herdr", "config.toml",
));
let text = "";
try { text = readFileSync(source, "utf8"); }
// A personal config is authoritative, including an omitted sidebar setting and relative paths.
try { readFileSync(source); return source; }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
// Preserve integer/float distinctions and large integers when serializing TOML.
const config = parse(text, { integersAsBigInt: true });
const ui = config["ui"];
if (ui !== undefined && (!ui || typeof ui !== "object" || Array.isArray(ui) || ui instanceof Date)) {
throw new Error(`Herdr's ui setting must be a table: ${source}`);
}
config["ui"] = { ...ui, sidebar_start_collapsed: true };
const bytes = stringify(config, { numbersAsFloat: true });
const key = createHash("sha256").update(source).update("\0").update(socketPath).digest("hex").slice(0, 20);
const collapsed = !(Number.isSafeInteger(columns) && columns! >= 160);
const bytes = `[ui]\nsidebar_start_collapsed = ${collapsed}\n`;
const key = createHash("sha256").update(source).update("\0").update(socketPath).update(String(collapsed)).digest("hex").slice(0, 20);
const target = path.join(path.dirname(source), `.openrig-herdr-${key}.toml`);
if (target === source) throw new Error("The private Herdr config must differ from the source config.");
mkdirSync(path.dirname(source), { recursive: true, mode: 0o700 });
+82 -22
View File
@@ -13,7 +13,7 @@ interface Pane { seat: string; label: string; paneCommand: string }
interface Preview {
planId: string;
status: { launch?: { socketPath: string; session?: string } };
composed: { id: string; opened: Pane[]; pages: Pane[][]; columns?: number; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
composed: { id: string; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
}
export interface WindowDeps {
@@ -24,7 +24,9 @@ export interface WindowDeps {
launch(file: string, args: string[]): Promise<void>;
sleep(ms: number): Promise<void>;
id(): string;
herdrConfig(socketPath: string): string;
herdrConfig(socketPath: string, columns?: number): string;
/** Hosting terminal measurement also works for an agent shell tool with piped stdio. */
columns?(): Promise<number | undefined>;
}
export function defaultWindowDeps(): WindowDeps {
@@ -41,7 +43,23 @@ export function defaultWindowDeps(): WindowDeps {
}),
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
id: () => randomUUID().slice(0, 12),
herdrConfig: socketPath => prepareHerdrLaunchConfig(env, socketPath),
herdrConfig: (socketPath, columns) => prepareHerdrLaunchConfig(env, socketPath, columns),
columns: async () => {
if (process.stdout.isTTY && process.stdout.columns > 0) return process.stdout.columns;
try {
const size = await run("/bin/sh", ["-c", "stty size < /dev/tty"], { encoding: "utf8", timeout: 1000 });
const width = Number(size.stdout.trim().split(/\s+/)[1]);
if (Number.isSafeInteger(width) && width > 0) return width;
} catch { /* A detached shell tool may still name its hosting tmux pane. */ }
if (process.env["TMUX"] && process.env["TMUX_PANE"]) {
try {
const size = await run("tmux", ["display-message", "-p", "-t", process.env["TMUX_PANE"], "#{window_width}"], { encoding: "utf8", timeout: 1000 });
const width = Number(size.stdout.trim());
if (Number.isSafeInteger(width) && width > 0) return width;
} catch { /* Unknown is a narrow layout, never an invented wide measurement. */ }
}
return undefined;
},
};
}
@@ -65,7 +83,7 @@ async function herdrBinary(deps: WindowDeps): Promise<string | null> {
}
/** New surfaces only. No System Events keystrokes or existing-terminal input. */
async function windowLauncher(deps: WindowDeps, notes: string[]): Promise<((command: string) => Promise<{ app: string; surface: string }>) | string> {
async function windowLauncher(deps: WindowDeps, notes: string[]): Promise<((command: string) => Promise<{ app: string; surface: string; columns?: number }>) | string> {
// Shell tools can pipe stdio while still running inside the person's terminal.
const program = deps.env["TERM_PROGRAM"];
const linuxHost = deps.platform !== "linux" ? undefined
@@ -87,8 +105,7 @@ async function windowLauncher(deps: WindowDeps, notes: string[]): Promise<((comm
} catch { /* No other app is a substitute for the hosting terminal. */ }
}
if (inGhostty && !ghostty) return "The hosting Ghostty does not provide a supported scripting interface.";
// Native macOS tab groups appear as separate one-tab Terminal windows.
// That is not evidence that resizing one would leave existing windows alone.
// A tab group shares bounds: copying its current bounds also leaves the original unchanged.
const script = ghostty ? `on run argv
tell application "Ghostty"
set cfg to new surface configuration
@@ -107,18 +124,24 @@ tell application "Ghostty"
end tell
end run` : `on run argv
tell application "Terminal"
do script (item 1 of argv)
set personBounds to missing value
if (count of windows) > 0 then set personBounds to bounds of front window
set viewTab to do script ""
set viewWindow to front window
if personBounds is not missing value then set bounds of viewWindow to personBounds
do script (item 1 of argv) in viewTab
activate
return "window:" & (number of columns of viewTab)
end tell
return "window"
end run`;
// A denied/uncertain Automation request is returned once, never replayed in another app.
return async command => {
const surface = (await deps.exec("/usr/bin/osascript", ["-e", script, command], 120_000)).trim();
const reported = (await deps.exec("/usr/bin/osascript", ["-e", script, command], 120_000)).trim();
const columns = Number(reported.split(":")[1]);
notes.push(ghostty
? "Ghostty's macOS scripting interface does not expose window size. Enlarge the new view manually if its columns are cramped; existing window settings were kept."
: "Terminal opened with its own window sizing. Resize or move the new view manually if needed; OpenRig did not request a size or position change.");
return { app: ghostty ? "Ghostty" : "Terminal", surface: ghostty ? surface : "window" };
? "Ghostty keeps the person's window size; layout uses the hosting terminal's measured width when available."
: "The new Terminal view copies the person's front-window bounds when available; existing window settings were kept.");
return { app: ghostty ? "Ghostty" : "Terminal", surface: ghostty ? reported : "window", ...(Number.isSafeInteger(columns) && columns > 0 ? { columns } : {}) };
};
}
if (deps.platform !== "linux") return `No supported desktop launcher on ${deps.platform}.`;
@@ -147,7 +170,7 @@ function failure(provider: string, error: string): OpenViewResult {
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string): Promise<OpenViewResult> {
let provider = requestedProvider ?? "herdr";
let window: { app: string; surface: string } | undefined;
let window: { app: string; surface: string; columns?: number } | undefined;
let viewer: string | undefined;
let windowAttempted = false;
const recovery = `rig terminal open ${shellQuote(view)} --window${requestedProvider && ["herdr", "tmux"].includes(requestedProvider) ? ` --provider ${shellQuote(requestedProvider)}` : ""}`;
@@ -160,25 +183,45 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (requestedProvider && !["herdr", "tmux"].includes(requestedProvider)) throw new Error("--window supports herdr or tmux. Use cmux without --window.");
const launchWindow = await windowLauncher(deps, windowNotes);
const measured = typeof launchWindow === "function" ? await deps.columns?.().catch(() => undefined) : undefined;
let viewportColumns = Number.isSafeInteger(measured) && measured! > 0 ? measured : undefined;
const herdr = requestedProvider === "tmux" ? null : await herdrBinary(deps);
if (!herdr && requestedProvider === "herdr") throw new Error("Herdr is not installed. Run rig setup, or use --provider tmux --window.");
provider = herdr ? "herdr" : "tmux";
// Preview is provider-neutral composition, including saved-view membership, absences and quoting.
const preview = await client.get<Preview | OpenViewResult>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=herdr`);
const previewUrl = () => `/api/terminal/preview?view=${encodeURIComponent(view)}&provider=herdr${viewportColumns === undefined ? "" : `&viewportColumns=${viewportColumns}`}`;
const preview = await client.get<Preview | OpenViewResult>(previewUrl());
if (preview.status >= 400 || !("composed" in preview.data)) {
return failed((preview.data as OpenViewResult).error ?? "Could not compose the requested view.");
}
const { composed, planId } = preview.data;
let { composed, planId } = preview.data;
if (expectedPlan !== undefined && expectedPlan !== planId) {
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
}
if (!composed.opened.length) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
const measureWindow = async () => {
if (window?.columns !== undefined && window.columns !== viewportColumns && composed.kernelLayout) {
viewportColumns = window.columns;
const next = await client.get<Preview | OpenViewResult>(previewUrl());
if (next.status >= 400 || !("composed" in next.data)) throw new Error("Could not compose the measured terminal view.");
if (expectedPlan !== undefined && expectedPlan !== next.data.planId) throw new Error("The measured terminal layout differs from the expected preview; refresh the preview before opening.");
({ composed, planId } = next.data);
if (!composed.opened.length) throw new Error("No conversations are attachable after opening the terminal.");
}
if (composed.kernelLayout) windowNotes.push(viewportColumns === undefined
? "Terminal width could not be measured; the welcome view uses the operator-only first page."
: `Welcome layout measured at ${viewportColumns} columns: ${viewportColumns >= 120 ? "dashboard and operator side by side; advisor on a separate page" : "operator first; dashboard and advisor on separate pages"}.`);
};
const socket = preview.data.status.launch?.socketPath;
let configEnv = "";
let configPrefix = "";
if (herdr && socket) {
try {
configEnv = ` HERDR_CONFIG_PATH=${shellQuote(deps.herdrConfig(socket))}`;
windowNotes.push("Herdr starts with the sidebar collapsed unless this endpoint has a saved choice; later toggles are kept.");
const narrow = deps.herdrConfig(socket);
const wide = typeof launchWindow === "function" ? deps.herdrConfig(socket, 160) : narrow;
if (narrow === wide) configEnv = ` HERDR_CONFIG_PATH=${shellQuote(narrow)}`;
else configPrefix = `set -- $(stty size 2>/dev/null); if [ "${'${2:-0}'}" -ge 160 ]; then export HERDR_CONFIG_PATH=${shellQuote(wide)}; else export HERDR_CONFIG_PATH=${shellQuote(narrow)}; fi; exec `;
windowNotes.push("Your Herdr config is kept when present; otherwise the sidebar starts open at 160 columns and collapsed below.");
} catch (error) {
windowNotes.push(`Could not prepare OpenRig's private Herdr settings (${error instanceof Error ? error.message : String(error)}); Herdr starts with its usual sidebar.`);
}
@@ -200,7 +243,9 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
if (!endpoint?.socketPath) throw new Error("The daemon does not report its herdr endpoint. Update the daemon, or use --provider tmux --window.");
// A CLI session would override the daemon's resolved socket in Herdr.
windowAttempted = true;
window = await launchWindow(`env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)}${configEnv} ${shellQuote(herdr)}`);
const command = `${configPrefix}env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)}${configEnv} ${shellQuote(herdr)}`;
window = await launchWindow(configPrefix ? `/bin/sh -c ${shellQuote(command)}` : command);
await measureWindow();
let alive = false;
for (let attempt = 0; attempt < 20; attempt++) {
const status = await client.get<{ providers: Array<{ liveness: { alive: boolean } }> }>("/api/terminal/status?provider=herdr");
@@ -255,7 +300,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
notes: [...windowNotes, "Existing workspace contents were kept; no layout refresh or new tiles were requested. Check the new terminal shows the intended view."],
};
}
const result = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr", expectedPlan: planId }, { timeoutMs: 45_000 });
const result = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr", expectedPlan: planId, ...(viewportColumns !== undefined ? { viewportColumns } : {}) }, { timeoutMs: 45_000 });
if (result.status >= 400) return { ...failed(result.data.error ?? `The daemon refused the view (HTTP ${result.status}).`), window, notes: windowNotes, absent: composed.absent, degraded: composed.degraded };
if (!Array.isArray(result.data?.opened)) throw new Error("The terminal opened, but the daemon returned no view result. Inspect it before retrying.");
return { ...result.data, window, notes: [...(result.data.notes ?? []), ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
@@ -264,12 +309,25 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"]).catch(() => "")).trim();
if (!tmux) throw new Error("tmux is unavailable; run rig setup first.");
viewer = `openrig-view-${deps.id()}`;
let placeholder: string | undefined;
if (composed.kernelLayout) {
// This is our fresh, empty viewer, never a source seat. Attach it first so Terminal can
// report the real new window width before we select the welcome layout.
placeholder = (await deps.exec(tmux, ["new-session", "-d", "-s", viewer, "-n", "view-1", "-P", "-F", "#{pane_id}"])).trim();
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
await measureWindow();
}
for (const [pageIndex, page] of composed.pages.entries()) {
if (!page.length) continue;
const name = `view-${pageIndex + 1}`;
const first = page[0]!;
const args = pageIndex === 0 ? ["new-session", "-d", "-s", viewer, "-n", name] : ["new-window", "-d", "-t", `${viewer}:`, "-n", name];
let pane = (await deps.exec(tmux, [...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
let pane: string;
if (pageIndex === 0 && placeholder) {
await deps.exec(tmux, ["respawn-pane", "-k", "-t", placeholder, `env -u TMUX ${first.paneCommand}`]);
pane = placeholder;
} else pane = (await deps.exec(tmux, [...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.
const [width, height] = (await deps.exec(tmux, ["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
@@ -285,8 +343,10 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
await deps.exec(tmux, ["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
}
await deps.exec(tmux, ["select-window", "-t", `${viewer}:view-1`]);
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
if (!window) {
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
}
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), absent: composed.absent, degraded: composed.degraded, pages: composed.pages.length, window, notes: [`Viewing session: ${viewer}. Existing conversations were preserved.`, ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
} catch (err) {
return { ...failed((err as Error).message), ...(window ? { window } : {}), ...(windowNotes.length || viewer ? { notes: [...windowNotes, ...(viewer ? [`Viewing session ${viewer} may exist. Inspect it before retrying; no existing conversation was replaced.`] : [])] } : {}) };
+37 -36
View File
@@ -10,62 +10,63 @@ function root() { const dir = mkdtempSync(path.join(tmpdir(), "herdr-config-"));
afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); });
const read = (file: string) => parse(readFileSync(file, "utf8"), { integersAsBigInt: true });
it("preserves the person's settings and config-relative paths, changing only the private sidebar default", () => {
it.each([80, 120, 159, 160, 268])("keeps the person's exact config at %i columns", columns => {
const dir = root(), source = path.join(dir, "my herdr.toml");
const text = '# keep my original formatting\n[ui]\nsidebar_start_collapsed = false\nsidebar_width = 31\n[terminal]\nfont_size = 12.0\n[theme]\nfile = "themes/personal.toml"\n[server]\nlarge_number = 9007199254740993\n[remote]\nservers = [{ name = "personal", address = "example.test" }]\n';
const text = '# keep my original formatting\n[ui]\nsidebar_start_collapsed = false\nsidebar_width = 31\n[terminal]\nfont_size = 12.0\n[theme]\nfile = "themes/personal.toml"\n[server]\nlarge_number = 9007199254740993\n';
writeFileSync(source, text, { mode: 0o640 });
const before = statSync(source);
const expected = parse(text, { integersAsBigInt: true });
expected.ui = { ...(expected.ui as object), sidebar_start_collapsed: true };
const env = { HERDR_CONFIG_PATH: source, XDG_STATE_HOME: path.join(dir, "saved-state") };
const originalEnv = { ...env };
const output = prepareHerdrLaunchConfig(env, "/owned/herdr.sock");
expect(read(output)).toEqual(expected);
expect(path.dirname(output)).toBe(path.dirname(source));
expect(readFileSync(output, "utf8")).toContain("font_size = 12.0");
expect(prepareHerdrLaunchConfig(env, "/owned/herdr.sock", columns)).toBe(source);
expect(readFileSync(source, "utf8")).toBe(text);
expect(statSync(source).ino).toBe(before.ino);
expect(statSync(source).mtimeMs).toBe(before.mtimeMs);
expect(statSync(source).mode).toBe(before.mode);
expect(statSync(output).mode & 0o777).toBe(0o600);
expect(env).toEqual(originalEnv);
expect(existsSync(env.XDG_STATE_HOME)).toBe(false);
expect(prepareHerdrLaunchConfig(env, "/owned/herdr.sock")).toBe(output);
expect(readdirSync(dir).sort()).toEqual([path.basename(source), path.basename(output)].sort());
expect(readdirSync(dir)).toEqual([path.basename(source)]);
});
it.each([false, true])("uses Herdr's normal HOME/XDG selection and leaves a missing global file absent (XDG: %s)", xdg => {
it.each([false, true])("preserves normal HOME/XDG config selection, including an omitted sidebar setting (XDG %s)", xdg => {
const dir = root(), env = { HOME: dir, ...(xdg ? { XDG_CONFIG_HOME: path.join(dir, "custom") } : {}) };
const output = prepareHerdrLaunchConfig(env, "/owned/herdr.sock");
expect(path.dirname(output)).toBe(path.join(xdg ? env.XDG_CONFIG_HOME! : path.join(dir, ".config"), "herdr"));
expect(read(output)).toEqual({ ui: { sidebar_start_collapsed: true } });
const configDir = path.join(xdg ? env.XDG_CONFIG_HOME! : path.join(dir, ".config"), "herdr");
mkdirSync(configDir, { recursive: true });
const source = path.join(configDir, "config.toml");
writeFileSync(source, '[ui]\nsidebar_width = 23\n');
expect(prepareHerdrLaunchConfig(env, "/owned.sock", 80)).toBe(source);
expect(read(source)).toEqual({ ui: { sidebar_width: 23n } });
});
it.each([[undefined, true], [80, true], [119, true], [120, true], [159, true], [160, false], [268, false]] as const)("defaults only an absent config at width %s to collapsed=%s", (columns, collapsed) => {
const dir = root(), env = { HOME: dir };
const output = prepareHerdrLaunchConfig(env, "/owned/herdr.sock", columns);
expect(read(output)).toEqual({ ui: { sidebar_start_collapsed: collapsed } });
expect(statSync(output).mode & 0o777).toBe(0o600);
expect(existsSync(path.join(path.dirname(output), "config.toml"))).toBe(false);
expect(prepareHerdrLaunchConfig(env, "/owned/herdr.sock", columns)).toBe(output);
});
it("reads an existing XDG config and scopes private copies to each endpoint", () => {
const dir = root(), configDir = path.join(dir, "herdr"); mkdirSync(configDir);
const source = path.join(configDir, "config.toml"); writeFileSync(source, '[ui]\nsidebar_width = 23\n');
const env = { XDG_CONFIG_HOME: dir };
const a = prepareHerdrLaunchConfig(env, "/one.sock"), b = prepareHerdrLaunchConfig(env, "/two.sock");
expect(a).not.toBe(b);
expect(read(a)).toEqual({ ui: { sidebar_width: 23n, sidebar_start_collapsed: true } });
expect(read(b)).toEqual(read(a));
expect(readFileSync(source, "utf8")).toBe('[ui]\nsidebar_width = 23\n');
it("keeps generated narrow/wide and endpoint settings independent", () => {
const env = { HOME: root() };
const narrow = prepareHerdrLaunchConfig(env, "/one.sock", 80);
const wide = prepareHerdrLaunchConfig(env, "/one.sock", 160);
const other = prepareHerdrLaunchConfig(env, "/two.sock", 160);
expect(new Set([narrow, wide, other]).size).toBe(3);
expect(read(narrow)).toEqual({ ui: { sidebar_start_collapsed: true } });
expect(read(wide)).toEqual({ ui: { sidebar_start_collapsed: false } });
});
it.each(['[ui\n', 'ui = "not a table"', 'ui = []'])("keeps malformed or non-table config unchanged instead of discarding it (%s)", text => {
it.each(['[ui\n', 'ui = "not a table"', 'ui = []', ''])("leaves interpretation of an existing config to Herdr: %s", text => {
const dir = root(), source = path.join(dir, "config.toml"); writeFileSync(source, text);
expect(() => prepareHerdrLaunchConfig({ HERDR_CONFIG_PATH: source }, "/owned.sock")).toThrow();
expect(prepareHerdrLaunchConfig({ HERDR_CONFIG_PATH: source }, "/owned.sock", 80)).toBe(source);
expect(readFileSync(source, "utf8")).toBe(text);
expect(readdirSync(dir)).toEqual(["config.toml"]);
});
it("replaces its private file atomically without following an output symlink", () => {
const dir = root(), source = path.join(dir, "config.toml"), env = { HERDR_CONFIG_PATH: source };
writeFileSync(source, '[ui]\nsidebar_width = 18\n');
const output = prepareHerdrLaunchConfig(env, "/owned.sock");
rmSync(output); symlinkSync(source, output);
expect(prepareHerdrLaunchConfig(env, "/owned.sock")).toBe(output);
expect(readFileSync(source, "utf8")).toBe('[ui]\nsidebar_width = 18\n');
expect(read(output)).toEqual({ ui: { sidebar_width: 18n, sidebar_start_collapsed: true } });
it("replaces its generated file atomically without following an output symlink", () => {
const dir = root(), victim = path.join(dir, "personal.toml"), env = { HOME: dir };
writeFileSync(victim, '[ui]\nsidebar_width = 18\n');
const output = prepareHerdrLaunchConfig(env, "/owned.sock", 160);
rmSync(output); symlinkSync(victim, output);
expect(prepareHerdrLaunchConfig(env, "/owned.sock", 160)).toBe(output);
expect(readFileSync(victim, "utf8")).toBe('[ui]\nsidebar_width = 18\n');
expect(read(output)).toEqual({ ui: { sidebar_start_collapsed: false } });
});
@@ -111,3 +111,48 @@ it.each([false, true])("opens real viewer panes and preserves source processes (
}
expect(tmux(source, ["list-panes", "-a", "-F", "#{session_name} #{pane_id} #{pane_pid}"])).toBe(original);
});
it.each([80, 119, 120, 174])("opens the measured %i-column welcome in an owned viewer without replacing source panes", async columns => {
const id = randomUUID().slice(0, 12);
const source = `ws-${id}`, viewer = `wv-${id}`;
sockets.push(source, viewer);
const names = ["operator", "dashboard", "advisor"];
for (const name of names) tmux(source, ["new-session", "-d", "-s", name, "sleep 60"]);
const snapshot = () => tmux(source, ["list-panes", "-a", "-F", "#{session_name}|#{pane_id}|#{pane_pid}"]);
const original = snapshot();
const panes = names.map(seat => ({ seat, label: seat, paneCommand: `tmux -L ${source} attach-session -t '=${seat}'` }));
const get = vi.fn(async (url: string) => {
const width = Number(new URL(url, "http://fixture").searchParams.get("viewportColumns"));
const pages = width >= 120 ? [[panes[1]!, panes[0]!], [panes[2]!]] : panes.map(pane => [pane]);
return { status: 200, data: { planId: "fixture", status: {}, composed: { id: "kernel", kernelLayout: "dual-runtime", opened: pages.flat(), pages, columns: width >= 120 ? 2 : 1, absent: [], degraded: [] } } };
});
const client = { baseUrl: "http://localhost:7433", get } as unknown as DaemonClient;
const desktop = vi.fn(async () => `window:${columns}`);
const deps: WindowDeps = {
herdrConfig: () => { throw new Error("tmux must not prepare Herdr config"); },
platform: "darwin", env: { TERM_PROGRAM: "Apple_Terminal", HOME: "/fixture" }, exists: () => false,
exec: async (file, args) => {
if (file === "/bin/sh") return "tmux";
if (file === "/usr/bin/osascript") return desktop();
if (file === "tmux") return tmux(viewer, args);
throw new Error(`unexpected executable ${file}`);
},
launch: vi.fn(async () => {}), sleep: async () => {}, id: () => id,
};
const result = await openTerminalWindow(client, "saved:kernel", "tmux", deps);
const expected = columns >= 120 ? [["dashboard", "operator"], ["advisor"]] : names.map(name => [name]);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, opened: expected.flat(), pages: expected.length });
expect(desktop).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(`/api/terminal/preview?view=saved%3Akernel&provider=herdr&viewportColumns=${columns}`);
const session = `openrig-view-${id}`;
expect(tmux(viewer, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}|#{window_active}"]).trim().split("\n"))
.toEqual(expected.map((_page, i) => `view-${i + 1}|${i === 0 ? 1 : 0}`));
for (const [index, page] of expected.entries()) {
const actual = tmux(viewer, ["list-panes", "-t", `${session}:view-${index + 1}`, "-F", "#{pane_title}|#{pane_top}|#{pane_width}"]).trim().split("\n").map(line => line.split("|"));
expect(actual.map(row => row[0])).toEqual(page);
expect(actual.map(row => row[1])).toEqual(page.map(() => "0"));
if (page.length === 2) expect(Math.abs(Number(actual[0]![2]) - Number(actual[1]![2]))).toBeLessThanOrEqual(1);
}
expect(snapshot()).toBe(original);
});
+82 -8
View File
@@ -1,12 +1,13 @@
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";
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}'` }));
const composed = { id: "kernel", opened: options.empty ? [] : panes, pages: options.empty ? [] : [panes], columns: 3, absent: [], degraded: [] };
const preview = { planId: "bound-plan", composed, status: { launch: { socketPath: "/daemon home/herdr.sock", session: "daemon-session" } } };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("preview") ? preview : { providers: [{ liveness: { alive: options.alive !== false } }] } }));
const get = vi.fn(async (url: string): Promise<{ status: number; data: unknown }> => ({ status: 200, data: url.includes("preview") ? preview : { providers: [{ liveness: { alive: options.alive !== false } }] } }));
const post = vi.fn(async () => options.refusal ? { status: options.refusal, data: { error: "view changed" } } : { status: 200, data: { provider: "herdr", ok: true, opened: panes.map(p => p.seat), absent: [], degraded: [], pages: 1 } });
const client = { baseUrl: "http://localhost:7433", get, post } as unknown as DaemonClient;
let pane = 0;
@@ -72,7 +73,7 @@ describe("desktop terminal view", () => {
expect(f.deps.herdrConfig).toHaveBeenCalledExactlyOnceWith("/daemon home/herdr.sock");
expect(result.notes).toEqual([
"After the person starts Herdr, have the agent place this view by running: rig terminal open 'saved:kernel' --provider herdr",
"Herdr starts with the sidebar collapsed unless this endpoint has a saved choice; later toggles are kept.",
"Your Herdr config is kept when present; otherwise the sidebar starts open at 160 columns and collapsed below.",
]);
expect(result.error).not.toContain("\n");
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript" || file === "/usr/bin/open")).toBe(false);
@@ -244,10 +245,11 @@ describe("desktop terminal view", () => {
expect(launch[1][1]).toContain("new tab in front window");
expect(launch[1][1]).toContain("new window with configuration");
expect(launch[1][1]).not.toMatch(/set (bounds|number of columns|number of rows)/);
expect(result.notes).toContain("Ghostty's macOS scripting interface does not expose window size. Enlarge the new view manually if its columns are cramped; existing window settings were kept.");
expect(result.notes).toContain("Ghostty keeps the person's window size; layout uses the hosting terminal's measured width when available.");
expect(launch[1][2]).toContain("HERDR_SOCKET_PATH='/daemon home/herdr.sock'");
expect(launch[1][2]).toContain("HERDR_CONFIG_PATH='/fixture/private herdr.toml'");
expect(f.deps.herdrConfig).toHaveBeenCalledExactlyOnceWith("/daemon home/herdr.sock");
expect(f.deps.herdrConfig).toHaveBeenNthCalledWith(1, "/daemon home/herdr.sock");
expect(f.deps.herdrConfig).toHaveBeenNthCalledWith(2, "/daemon home/herdr.sock", 160);
expect(launch[1][2]).not.toContain("--session");
expect(launch[1][2]).toContain("-u HERDR_SESSION");
expect(launch[1][2]).not.toContain("wrong-session");
@@ -262,16 +264,21 @@ describe("desktop terminal view", () => {
expect(script).toContain('tell application "Terminal"');
expect(script).toContain("do script (item 1 of argv)");
expect(script).not.toContain("in front window");
expect(script).not.toMatch(/number of (columns|rows)|bounds|position|size|tabs of|repeat with/);
expect(script).toContain("set personBounds to bounds of front window");
expect(script).toContain("set bounds of viewWindow to personBounds");
expect(script.indexOf("set personBounds")).toBeLessThan(script.indexOf('do script ""'));
expect(script.indexOf("set bounds")).toBeLessThan(script.indexOf("do script (item 1 of argv) in viewTab"));
expect(script).toContain("number of columns of viewTab");
expect(script).not.toMatch(/set number of (columns|rows)|set position/);
expect(script).not.toMatch(/settings set|default settings|System Events/);
expect(result.notes).toContain("Terminal opened with its own window sizing. Resize or move the new view manually if needed; OpenRig did not request a size or position change.");
expect(result.notes).toContain("The new Terminal view copies the person's front-window bounds when available; existing window settings were kept.");
});
it("keeps the Terminal view and reports manual sizing without reopening", async () => {
it("copies the existing Terminal bounds without reopening", async () => {
const f = fixture();
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result).toMatchObject({ ok: true, window: { app: "Terminal", surface: "window" } });
expect(result.notes?.join(" ")).toContain("Resize or move the new view manually");
expect(result.notes?.join(" ")).toContain("copies the person's front-window bounds");
expect(result.notes?.join(" ")).not.toContain("tab reports 140");
expect(f.exec.mock.calls.filter(([file]) => file === "/usr/bin/osascript")).toHaveLength(1);
expect(f.post).toHaveBeenCalledTimes(1);
@@ -401,3 +408,70 @@ describe("desktop terminal view", () => {
expect(result.notes?.join(" ")).toContain("Requested 140 columns by 40 rows");
});
});
// Synthetic desktop results test the CLI/daemon width binding; native window geometry is a separate check.
describe("measured welcome layout", () => {
function kernelFixture(herdr = true) {
const f = fixture({ herdr });
Object.assign(f.preview.composed, { kernelLayout: "dual-runtime" });
f.get.mockImplementation(async url => {
if (!url.includes("preview")) return { status: 200, data: { providers: [{ liveness: { alive: true } }] } };
const columns = Number(new URL(url, "http://fixture").searchParams.get("viewportColumns"));
const [dashboard, advisor, operator] = f.preview.composed.opened;
const pages = columns >= 120 ? [[dashboard!, operator!], [advisor!]] : [[operator!], [dashboard!], [advisor!]];
return { status: 200, data: { ...f.preview, planId: columns >= 120 ? "wide" : "narrow", composed: { ...f.preview.composed, pages, opened: pages.flat(), columns: columns >= 120 ? 2 : 1 } } };
});
return f;
}
it.each([119, 120, 159, 160, 174])("binds the Terminal-reported width %i to preview and open", async columns => {
const f = kernelFixture();
const original = f.deps.exec;
f.deps.exec = vi.fn(async (file, args) => file === "/usr/bin/osascript" ? `window:${columns}` : original(file, args));
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.ok).toBe(true);
expect(f.get).toHaveBeenCalledWith(`/api/terminal/preview?view=saved%3Akernel&provider=herdr&viewportColumns=${columns}`);
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "saved:kernel", provider: "herdr", viewportColumns: columns, expectedPlan: columns >= 120 ? "wide" : "narrow" }, { timeoutMs: 45_000 });
expect(vi.mocked(f.deps.exec).mock.calls.filter(([file]) => file === "/usr/bin/osascript")).toHaveLength(1);
});
it("uses a Ghostty shell tool's hosting-terminal measurement without requiring a stdin TTY", async () => {
const f = kernelFixture();
f.deps.env = { TERM_PROGRAM: "ghostty" };
f.deps.exists = () => true;
f.deps.columns = async () => 120;
const original = f.deps.exec;
f.deps.exec = async (file, args) => file === "/usr/libexec/PlistBuddy" ? "1.3.1" : file === "/usr/bin/osascript" ? "tab" : original(file, args);
expect((await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps)).ok).toBe(true);
expect(f.post).toHaveBeenCalledWith("/api/terminal/open", expect.objectContaining({ viewportColumns: 120, expectedPlan: "wide" }), expect.anything());
});
it("leaves an unknown width narrow and says so", async () => {
const f = kernelFixture();
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.ok).toBe(true);
expect(result.notes?.join(" ")).toContain("width could not be measured");
expect(f.post).toHaveBeenCalledWith("/api/terminal/open", { view: "saved:kernel", provider: "herdr", expectedPlan: "narrow" }, expect.anything());
});
it("does not silently replace an explicit expected plan after measuring a different layout", async () => {
const f = kernelFixture();
const original = f.deps.exec;
f.deps.exec = async (file, args) => file === "/usr/bin/osascript" ? "window:174" : original(file, args);
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps, "narrow");
expect(result).toMatchObject({ ok: false, windowAttempted: true });
expect(result.error).toContain("differs from the expected preview");
expect(f.post).not.toHaveBeenCalled();
});
it("chooses generated sidebar settings in the new terminal, after its bounds are copied", async () => {
const f = kernelFixture();
f.deps.herdrConfig = (_socket, columns) => columns === 160 ? "/fixture/wide.toml" : "/fixture/narrow.toml";
await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
const launch = f.exec.mock.calls.find(([file]) => file === "/usr/bin/osascript")!;
// Terminal passes this line to the login shell, which need not understand POSIX conditionals.
const command = `set -- $(stty size 2>/dev/null); if [ "${'${2:-0}'}" -ge 160 ]; then export HERDR_CONFIG_PATH='/fixture/wide.toml'; else export HERDR_CONFIG_PATH='/fixture/narrow.toml'; fi; exec env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH='/daemon home/herdr.sock' '/fixture/bin/herdr'`;
expect(launch[1][2]).toBe(`/bin/sh -c ${shellQuote(command)}`);
expect(launch[1][1]!.indexOf("set bounds")).toBeLessThan(launch[1][1]!.indexOf("do script (item 1 of argv) in viewTab"));
});
});
@@ -54,8 +54,9 @@ For “show me my agents”, “show me the terminals”, “see my agents” or
`rig terminal open saved:kernel --window --json` on the daemon's desktop. The request is already permission to
open it; otherwise ask **“Open the OpenRig view now?”** first. The command opens a new terminal tab/window using
installed herdr, otherwise plain tmux; it preserves your terminal and existing conversations. The default view
needs no YAML edit. Show the operator in one full-width pane for Claude-only, Codex-only and mixed kernels.
The dashboard and advisor each remain available in their own tab or tmux window.
needs no YAML edit. Below 120 measured columns (or unknown width), show the operator alone first; from 120,
show dashboard and operator equally side by side. The advisor always has a separate tab or tmux window.
This applies to Claude-only, Codex-only and mixed kernels.
Keep the queue worker off the first view and accessible through the TUI. Reuse existing conversations and accounts.
Inspect the result and visible content using authorized desktop tools, or report that visibility is unconfirmed.
Herdr is visible only inside a terminal the person can see. Creating a workspace or switching the shared TUI
+3 -2
View File
@@ -38,8 +38,9 @@ guidance in `AGENTS.md` or `CLAUDE.md` plus the team's skills and plugins. Say s
Keep it.
- **Open the welcome screen:** for “show me my agents”, “show me the terminals”, “see my agents” or “welcome screen”, run
`rig terminal open saved:kernel --window` on the daemon's desktop. Otherwise ask “Open the OpenRig view now?”
first. It opens a new terminal tab/window itself with one full-width operator pane, using herdr when installed
or plain tmux otherwise. The dashboard and advisor each have their own tab or tmux window; select one to switch.
first. It opens a new terminal tab/window itself, using herdr when installed or plain tmux otherwise.
Below 120 measured columns (or unknown width), the operator fills the first page; from 120, dashboard and operator
share it equally. The advisor always has a separate tab or tmux window; select one to switch.
Preserve the current terminal. Check the result and visible content, or report what cannot be
verified. If the view is already open, point them to it rather than opening another. On a desktop, do not finish by showing a table or suggesting a command for the person to type. Only if the window
cannot open, `rig tui --shared` is the dashboard-only fallback; explain the failure and help with the chosen
@@ -13,8 +13,8 @@ these plain commands let the health check finish without that extra prompt.
For “show me my agents”, “show me the terminals”, “see my agents” or “welcome screen”, run
`rig terminal open saved:kernel --window` on the daemon's desktop. It opens the
operator in one full-width pane in a new terminal tab/window. The dashboard and advisor
each have their own tab or tmux window. Inspect its result and visible content, or state what you cannot confirm. On a desktop, do
welcome view in a new terminal tab/window: below 120 measured columns, operator alone first;
from 120, dashboard and operator equally side by side. The advisor has a separate tab or tmux window. Inspect its result and visible content, or state what you cannot confirm. On a desktop, do
not end by showing a table or suggesting a command for the person to type. Herdr is visible
only inside a terminal the person can see; changing the shared TUI to `:terminals`
does not open one. Over headless SSH, explain that limitation, ask the person to
@@ -17,8 +17,8 @@ machine and have things happen.
approval escalate.
- **operator.human** is the shared mission-control terminal. A fresh kernel
starts `rig tui` there. For “show me my agents”, “show me the terminals” or “welcome screen”, run
`rig terminal open saved:kernel --window` to open the operator first, with the dashboard and advisor
in their own tabs or tmux windows. Only if
`rig terminal open saved:kernel --window`: below 120 measured columns, operator first; from 120,
dashboard and operator equally side by side. The advisor keeps a separate tab or tmux window. Only if
the window cannot open, `rig tui --shared` is the dashboard-only fallback;
detaching preserves navigation. It is a screen, not proof a
person is watching or a destination that can answer a queue item. Use the
@@ -62,6 +62,8 @@ export interface OpenViewRequest {
view: string;
/** Preview fingerprint. A changed membership/layout must be previewed again. */
expectedPlan?: string;
/** Measured width of the viewing terminal; unknown widths keep the narrow default. */
viewportColumns?: number;
}
export interface TerminalPreview {
@@ -174,7 +176,7 @@ export class TerminalService {
);
}
const composed = await this.resolveComposed(req.view, provider.panesPerPage);
const composed = await this.resolveComposed(req.view, provider.panesPerPage, req.viewportColumns);
if ("code" in composed) return errorResult(providerName, composed.code, composed.error);
const planId = this.planId(providerName, composed);
if (req.expectedPlan !== undefined && req.expectedPlan !== planId) {
@@ -199,12 +201,19 @@ export class TerminalService {
return notes.length ? { ...result, notes: [...notes, ...(result.notes ?? [])] } : result;
}
private async resolveComposed(viewArg: string, panesPerPage?: number): Promise<ComposedTerminalView | { code: string; error: string }> {
private async resolveComposed(viewArg: string, panesPerPage?: number, viewportColumns?: number): Promise<ComposedTerminalView | { code: string; error: string }> {
const view = (viewArg ?? "").trim();
if (!view) return { code: "view_required", error: "a view argument is required" };
const resolved = await this.resolveView(view);
if ("code" in resolved) return resolved;
const composed = composeView(resolved.id, await this.refineLiveness(resolved.members), { resolveHost: (id) => this.deps.resolveHost(id), panesPerPage: resolved.panesPerPage ?? panesPerPage, localTmux: await this.resolveLocalTmux() });
if (resolved.kernelLayout && Number.isSafeInteger(viewportColumns) && viewportColumns! >= 120) {
// The default's members are operator, dashboard, advisor. Keep the advisor separate
// even when one of the other roles is unavailable; chunking two at a time would not.
const page = (indexes: number[]) => indexes.flatMap(index => composed.opened.filter(pane => pane.seat === resolved.members[index]?.seat));
const pages = [page([1, 0]), page([2])].filter(panes => panes.length > 0);
return { ...composed, opened: pages.flat(), pages, kernelLayout: resolved.kernelLayout, columns: 2 };
}
return resolved.kernelLayout ? { ...composed, kernelLayout: resolved.kernelLayout, columns: resolved.columns } : composed;
}
@@ -217,7 +226,7 @@ export class TerminalService {
const providerName = (req.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const provider = this.deps.resolveProvider(providerName);
if (!provider) return errorResult(providerName, "unknown_provider", `unknown provider '${providerName}'`);
const composed = await this.resolveComposed(req.view, provider.panesPerPage);
const composed = await this.resolveComposed(req.view, provider.panesPerPage, req.viewportColumns);
if ("code" in composed) return errorResult(providerName, composed.code, composed.error);
return { provider: providerName, view: req.view, composed, grids: composed.pages.map(page => buildGridRoot(page, composed.columns)), planId: this.planId(providerName, composed), status: await provider.status() };
}
+7 -5
View File
@@ -36,13 +36,14 @@ function statusForOpen(ok: boolean, code: string | undefined): 200 | 400 | 404 |
}
/** Parse the `{ provider?, view }` open body honestly (a non-object / missing view → structured 400 upstream). */
function readOpenBody(raw: unknown): { provider?: string; view?: string; expectedPlan?: string } {
function readOpenBody(raw: unknown): { provider?: string; view?: string; expectedPlan?: string; viewportColumns?: number } {
if (raw === null || typeof raw !== "object") return {};
const obj = raw as Record<string, unknown>;
const provider = typeof obj["provider"] === "string" ? (obj["provider"] as string) : undefined;
const view = typeof obj["view"] === "string" ? (obj["view"] as string) : undefined;
const expectedPlan = typeof obj["expectedPlan"] === "string" ? obj["expectedPlan"] : undefined;
return { ...(provider !== undefined ? { provider } : {}), ...(view !== undefined ? { view } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) };
const viewportColumns = typeof obj["viewportColumns"] === "number" ? obj["viewportColumns"] : undefined;
return { ...(provider !== undefined ? { provider } : {}), ...(view !== undefined ? { view } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}), ...(viewportColumns !== undefined ? { viewportColumns } : {}) };
}
/** The canonical, non-rig-scoped terminal route family. Mounted at `/api/terminal`. */
@@ -58,8 +59,8 @@ export function terminalRoutes(): Hono {
} catch {
return c.json({ error: "body_invalid", hint: "expected a JSON object { provider?, view }" }, 400);
}
const { provider, view, expectedPlan } = readOpenBody(raw);
const result = await svc.openView({ ...(provider !== undefined ? { provider } : {}), view: view ?? "", ...(expectedPlan !== undefined ? { expectedPlan } : {}) });
const { provider, view, expectedPlan, viewportColumns } = readOpenBody(raw);
const result = await svc.openView({ ...(provider !== undefined ? { provider } : {}), view: view ?? "", ...(expectedPlan !== undefined ? { expectedPlan } : {}), ...(viewportColumns !== undefined ? { viewportColumns } : {}) });
return c.json(result, statusForOpen(result.ok, result.code));
});
@@ -72,7 +73,8 @@ export function terminalRoutes(): Hono {
app.get("/preview", async (c) => {
const svc = getService(c);
if (!svc) return c.json({ error: "terminal_service_unavailable" }, 503);
const result = await svc.previewView({ view: c.req.query("view") ?? "", provider: c.req.query("provider") });
const width = c.req.query("viewportColumns");
const result = await svc.previewView({ view: c.req.query("view") ?? "", provider: c.req.query("provider"), ...(width !== undefined ? { viewportColumns: Number(width) } : {}) });
return c.json(result, "planId" in result ? 200 : statusForOpen(result.ok, result.code));
});
+16 -1
View File
@@ -7,7 +7,7 @@
// - service absent → 503 (never a crash);
// - the rig-scoped alias composes view = rig:<rigId> and delegates (arch R1).
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { Hono } from "hono";
import { terminalRoutes, rigTerminalRoutes } from "../src/routes/terminal.js";
import type { OpenViewResult } from "../src/domain/terminal/terminal-provider.js";
@@ -131,3 +131,18 @@ describe("POST /api/rigs/:rigId/terminal/open — thin alias", () => {
expect(openCalls[0]).toEqual({ view: "rig:rig-id-1", provider: "cmux" });
});
});
describe("measured welcome width at the HTTP boundary", () => {
it.each([119, 120, 159, 160])("passes %i columns unchanged from preview to open", async viewportColumns => {
const previewView = vi.fn(async () => ({ planId: "width-plan" }));
const openView = vi.fn(async () => okResult("saved:kernel"));
const { app } = makeApp({ service: { previewView, openView } });
const preview = await app.request(`/api/terminal/preview?view=saved:kernel&provider=herdr&viewportColumns=${viewportColumns}`);
expect(preview.status).toBe(200);
expect(previewView).toHaveBeenCalledExactlyOnceWith({ view: "saved:kernel", provider: "herdr", viewportColumns });
const opened = await post(app, "/api/terminal/open", { view: "saved:kernel", provider: "herdr", viewportColumns, expectedPlan: "width-plan" });
expect(opened.status).toBe(200);
expect(openView).toHaveBeenCalledExactlyOnceWith({ view: "saved:kernel", provider: "herdr", viewportColumns, expectedPlan: "width-plan" });
});
});
+52 -2
View File
@@ -400,15 +400,65 @@ describe("default saved kernel conversations", () => {
expect(planHerdrLayout(herdr.lastView!, "fixed").pages.map(page => page.root)).toEqual(preview.grids.map(grid => grid.root));
});
it.each([
[119, "claude-code", "codex"], [120, "claude-code", "codex"], [174, "claude-code", "codex"],
[119, "claude-code", "claude-code"], [120, "claude-code", "claude-code"],
[119, "codex", "codex"], [160, "codex", "codex"],
] as const)("uses the measured %i-column welcome layout for %s/%s", async (viewportColumns, advisor, operator) => {
const { deps, herdr } = makeKernel(kernel(operator, advisor));
const service = new TerminalService(deps);
const preview = await service.previewView({ view: "saved:kernel", viewportColumns });
if (!("composed" in preview)) throw new Error("expected preview");
const wide = viewportColumns >= 120;
expect(preview.composed.pages.map(page => page.map(pane => pane.seat))).toEqual(wide
? [["tui-bound", "operator-bound"], ["advisor-bound"]]
: [["operator-bound"], ["tui-bound"], ["advisor-bound"]]);
if (wide) expect(preview.grids[0]!.root).toMatchObject({
type: "split", direction: "right", ratio: 0.5,
first: { type: "pane", label: "operator.human" }, second: { type: "pane", label: "operator.agent" },
});
expect(preview.grids.every(grid => grid.blanks === 0)).toBe(true);
expect((await service.openView({ view: "saved:kernel", viewportColumns, expectedPlan: preview.planId })).ok).toBe(true);
expect(planHerdrLayout(herdr.lastView!, "fixed").pages.map(page => page.root)).toEqual(preview.grids.map(grid => grid.root));
});
it.each(["operator.agent", "operator.human"])("keeps the advisor separate at 160 columns when %s is absent", async missing => {
const { deps } = makeKernel(kernel().filter(row => row.logicalId !== missing));
const preview = await new TerminalService(deps).previewView({ view: "saved:kernel", viewportColumns: 160 });
if (!("composed" in preview)) throw new Error("expected preview");
expect(preview.composed.pages.map(page => page.map(pane => pane.seat))).toEqual([
[missing === "operator.agent" ? "tui-bound" : "operator-bound"], ["advisor-bound"],
]);
expect(preview.composed.absent.map(pane => pane.seat)).toContain(missing);
expect(preview.grids.every(grid => grid.blanks === 0)).toBe(true);
});
it("binds preview fingerprints to the width-selected composition", async () => {
const { deps, herdr } = makeKernel(kernel());
const service = new TerminalService(deps);
const preview = await service.previewView({ view: "saved:kernel", viewportColumns: 119 });
if (!("composed" in preview)) throw new Error("expected preview");
const result = await service.openView({ view: "saved:kernel", viewportColumns: 120, expectedPlan: preview.planId });
expect(result).toMatchObject({ ok: false, code: "preview_changed" });
expect(herdr.lastView).toBeNull();
});
it.each([undefined, 0, -1, NaN, Infinity, 120.5])("uses the narrow layout for an unknown or invalid width %s", async viewportColumns => {
const { deps } = makeKernel(kernel());
const preview = await new TerminalService(deps).previewView({ view: "saved:kernel", viewportColumns });
if (!("composed" in preview)) throw new Error("expected preview");
expect(preview.composed.pages.map(page => page.map(pane => pane.seat))).toEqual([["operator-bound"], ["tui-bound"], ["advisor-bound"]]);
});
it.each(["saved:kernel", "saved:custom", "rig:kernel"])("kernel geometry preserves auto-grid for %s outside the default", async view => {
const custom: SavedView = { id: view.slice(6), name: "Custom", members: ["a", "b", "c"].map(seat => ({ seat })) };
const { deps, herdr } = makeKernel(kernel().filter(row => row.logicalId !== "queue.worker"), [custom]);
const service = new TerminalService(deps);
const preview = await service.previewView({ view });
const preview = await service.previewView({ view, viewportColumns: 174 });
if (!("composed" in preview)) throw new Error("expected preview");
expect(preview.composed.columns).toBeUndefined();
expect(preview.grids[0]).toMatchObject({ columns: 2, rows: 2, blanks: 1 });
expect((await service.openView({ view, expectedPlan: preview.planId })).ok).toBe(true);
expect((await service.openView({ view, viewportColumns: 174, expectedPlan: preview.planId })).ok).toBe(true);
expect(planHerdrLayout(herdr.lastView!, "fixed").pages.map(page => page.root)).toEqual(preview.grids.map(grid => grid.root));
});
+3 -2
View File
@@ -38,8 +38,9 @@ guidance in `AGENTS.md` or `CLAUDE.md` plus the team's skills and plugins. Say s
Keep it.
- **Open the welcome screen:** for “show me my agents”, “show me the terminals”, “see my agents” or “welcome screen”, run
`rig terminal open saved:kernel --window` on the daemon's desktop. Otherwise ask “Open the OpenRig view now?”
first. It opens a new terminal tab/window itself with one full-width operator pane, using herdr when installed
or plain tmux otherwise. The dashboard and advisor each have their own tab or tmux window; select one to switch.
first. It opens a new terminal tab/window itself, using herdr when installed or plain tmux otherwise.
Below 120 measured columns (or unknown width), the operator fills the first page; from 120, dashboard and operator
share it equally. The advisor always has a separate tab or tmux window; select one to switch.
Preserve the current terminal. Check the result and visible content, or report what cannot be
verified. If the view is already open, point them to it rather than opening another. On a desktop, do not finish by showing a table or suggesting a command for the person to type. Only if the window
cannot open, `rig tui --shared` is the dashboard-only fallback; explain the failure and help with the chosen