mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb31f8ef0 | ||
|
|
c1c922651f | ||
|
|
0d5beddae3 | ||
|
|
0b47be8335 |
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.`] : [])] } : {}) };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user