Install Herdr and open the welcome view in a new terminal (#936)

Setup installs herdr by default and rig terminal open --window opens the welcome view in a new terminal, so the person copies no command; a failed herdr install is a warning and setup continues.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-07 09:26:53 -07:00
1 parent 72ce7e7ae5
commit 6cdb203054
15 files changed
+780 -620

No files matched your search

+3 -3
View File
@@ -394,9 +394,9 @@ Optional:
## Setup and Troubleshooting
- `rig setup` attempts core machine preparation: it installs a missing Claude Code or Codex and writes tmux defaults. On macOS it also installs a missing tmux and cmux with an existing Homebrew; elsewhere install tmux yourself (setup only checks it). cmux is optional and macOS-only; elsewhere setup skips it and `rig doctor` only warns that it's missing. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup` attempts core machine preparation: it installs a missing Claude Code or Codex and writes tmux defaults. It installs Herdr by default on macOS and Linux; use `--no-herdr` if you decline it. On macOS it also installs a missing tmux with an existing Homebrew; elsewhere install tmux yourself (setup only checks it). On a Mac, the installing agent offers Ghostty once; accepting uses `rig setup --ghostty`, and declining leaves Terminal.app available. Existing cmux settings are preserved. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup --full` attempts a broader operator workstation setup (jq, gh, via Homebrew, so macOS only) on top of core.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes. It does not check provider logins or whether agents can work: also run `claude auth status` or `codex login status`, and `rig ps --nodes --rig <rig>`.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes. It shares setup's local provider login checks. These do not prove provider acceptance or a working agent; also inspect `rig ps --nodes --rig <rig>`.
Both commands support `--json` for agent-driven workflows.
@@ -404,7 +404,7 @@ Before setup or managed launch, review [what OpenRig changes on your machine](#w
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Then start the daemon if it is stopped, ask “Open the OpenRig view now?”, and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Then start the daemon if it is stopped, ask “Open the OpenRig view now?”, run `rig terminal open saved:kernel --window --json` on yes (no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
## Comparison with Claude Managed Agents
+80 -148
View File
@@ -85,13 +85,14 @@ Install OpenRig (`npm install -g @openrig/cli`) and check `tmux -V`. With npm 11
ask once: “Please run `codex login` in your launch environment.”
Install a missing selected CLI using its provider's installation instructions.
The other provider's CLI/login and Herdr/cmux are optional. Do not copy credentials
The other provider's CLI/login is optional. Herdr is installed by default unless
you decline it; cmux remains optional. Do not copy credentials
or start repeated sign-in attempts. Recheck the selected login after the user
completes it. `rig setup --dry-run` previews the broader setup; applying
`rig setup` checks **both** harnesses and installs a missing one with npm; on
macOS it uses an existing Homebrew (it does not install Homebrew) to install a
missing tmux and cmux (off macOS those steps are skipped, and tmux isn't
installed for you). It also
missing tmux (elsewhere tmux is checked, not installed). Herdr installs on
macOS and Linux; pass `--no-herdr` to decline it. Setup also
writes an OpenRig-managed block (mouse on, a longer history) into `~/.tmux.conf`.
It is optional for this selected-provider path, not a requirement to fix an unused
provider.
@@ -136,187 +137,118 @@ when it starts the daemon.
## Open the kernel conversations
`rig setup` installs Herdr by default on macOS and Linux. If the person declines
Herdr, use `rig setup --no-herdr`; the welcome view can use plain tmux. Setup
checks both PATH and the installer's default `~/.local/bin/herdr` location. It
leaves existing cmux settings alone and does not open a view during setup.
On a Mac, the installing agent also asks once, "Install Ghostty for the OpenRig
view?" On Yes it reruns setup with `--ghostty`, preserving earlier choices such
as `--no-herdr`. This installs the
[documented Homebrew cask](https://ghostty.org/docs/install/binary) and checks for
the app. On No it uses `--no-ghostty`; Terminal.app remains available. Plain setup
reports this offer without installing Ghostty or waiting for input. The person
types no command.
The installing agent asks: **“Open the OpenRig view now?”**
- **Yes:** open a new space with the installed tools below. Keep the terminal in
use and existing windows intact, including an agent's installation session.
- **No:** give the matching command so the person can open it later. Leave the
current terminal alone.
- **Over SSH or without a local display:** give the exact connection/attach
command for the installation host instead of trying to open a window here.
No, SSH and headless use are valid background outcomes, not setup failures.
Use **herdr first if installed**, **cmux second**, and otherwise give the exact
[new-terminal commands below](#plain-terminal-a-new-viewing-session).
To reach just the operator, use the [direct conversation command](#talk-to-the-operator-in-any-terminal).
`rig tui --shared` is the team dashboard, not the operator's conversation.
No new terminal-provider installation is needed for this offer. The first view
contains TUI | advisor | operator in that order, for Claude-only, Codex-only and
mixed kernels.
The queue worker stays out of this view; it remains reachable through the TUI.
This selects what you see, not which kernel seats run. Keep the current kernel,
accounts and conversations; opening a view is not a reason to start or restore
seats. If the kernel is starting, report that; opening the view need not wait.
If it is absent or blocked, follow
[Incomplete setup and restart](#incomplete-setup-and-restart).
### Confirm the existing seats
Run these on the daemon's machine, in the installation environment:
- **Yes:** the installing agent runs the command below. It opens a new terminal
tab or window itself. The person copies and types no commands.
- **No:** leave the current terminal alone and offer to open the view later.
- **Over SSH or without a local display:** explain that no local desktop view
was opened. Background use remains valid; see the headless handoff below.
```sh
rig status
rig ps --nodes --rig kernel --json --fields logicalId,runtime,canonicalSessionName,tmuxAttachCommand
rig terminal status --json
rig terminal open saved:kernel --window --json
```
Use the returned bindings, not the library preview. Find the rows whose
`logicalId` is `advisor.lead`, `operator.agent` and `operator.human` (the TUI).
For each, take its `canonicalSessionName`; a logical ID is not a tmux session
name. The default provider view resolves these values itself; use them directly for
the plain-terminal commands below. Missing bindings stay named missing; the
three-role view is the same regardless of the agents' runtimes.
Run this on the daemon's desktop, as the same user. The command prefers a new
Ghostty tab on macOS when Ghostty 1.3 or newer is installed, otherwise a new
Terminal window. With no existing window, including an installation run from
Claude Desktop, it creates one. macOS may ask for Automation permission; let the
person answer that system prompt. A denied or uncertain action is reported once,
not replayed in another app. On Linux a local graphical display and a supported
terminal are required (Ghostty, the system terminal, GNOME Terminal, Konsole or
xterm). Linux reports a window request; verify what actually appeared.
### Herdr or cmux: one new workspace
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
latter with `--provider tmux --window`. Neither route types into or replaces the
installing agent's terminal. Existing conversations continue in their original
sessions.
OpenRig supplies `saved:kernel` automatically from the installed kernel's current
bindings: TUI | advisor | operator for every kernel. No YAML edit or daemon
restart is needed. It excludes
the queue worker and uses the existing conversations. Unavailable expected roles
are named in `absent`; with no attachable members, the result lists the missing
bindings. The runtime label describes the agents' runtime mix, or says that it is
unverified. Every default kernel view keeps the same three roles.
### Confirm the view
If you already saved a view with id `kernel` in `terminal-views.yaml`, your view
wins unchanged. Other saved views are preserved. Check the listed membership
before opening a custom view.
The default `saved:kernel` composition is **TUI | advisor | operator**, in three
columns, for Claude-only, Codex-only and mixed kernels. The queue worker remains
reachable through the TUI. 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.
Check `rig terminal status --json` for provider availability and liveness. If
herdr is installed but closed, open the app normally and check again. When it
is available:
Inspect `opened`, `absent`, `degraded`, `window` and any notes. Confirm the new
surface visibly shows the intended conversations and TUI, and that the original
terminal remains intact. A created window or successful CLI response alone is
not visual confirmation. A partial view remains partial. If the shared TUI tile
shows a shell, the installing agent starts `rig tui` in that new tile.
Opening the view can happen while the kernel finishes starting. Report its actual
state; do not start or restore seats just to obtain a view. If the kernel is absent
or blocked, follow [Incomplete setup and restart](#incomplete-setup-and-restart).
After a failure, inspect any newly opened surface before retrying. A tmux failure
may name a newly created viewing session for inspection. Do not replace a failed
local desktop action with commands for the person to copy; finish or explain the
failed action. Close only the viewing window, or use **Ctrl-b, then d** in plain
tmux, to leave the underlying conversations running.
### Existing provider workspaces
The existing provider-only commands remain available when a terminal is already
open. They create a workspace inside that provider, without opening an OS window:
```sh
rig terminal views --json
rig terminal open saved:kernel --provider herdr --json
```
If herdr is unavailable, use a running cmux:
```sh
rig terminal open saved:kernel --provider cmux --json
```
These view opens create a fresh provider workspace. Inspect `opened`, `absent`,
`degraded` and any notes, then confirm the new workspace actually shows the
intended conversations and TUI. A partial result is not a complete handoff.
For herdr, open or attach the actual herdr session in the new space and check
what the person sees. A created workspace is not proof of a visible window;
running the CLI in a new OS terminal window does not show that workspace there.
Do not use `rig terminal open kernel` for this first view: it also includes the
queue worker. A failed or uncertain open is not evidence that nothing opened;
inspect the provider before retrying or falling back. If the shared TUI tile
shows a shell, run `rig tui` **in that new tile**, not in the installing agent's
terminal.
Use `rig terminal status --json` to inspect provider availability and liveness.
For the first desktop handoff, use `--window`. `rig tui --shared` is the team
dashboard, not the operator's conversation.
### Talk to the operator in any terminal
No herdr or cmux is needed. On the kernel's host, run:
For an explicitly requested manual attachment, inspect the current bindings:
```sh
rig ps --nodes --rig kernel --json
```
Find the row whose `logicalId` is `operator.agent`. Replace the placeholder below
with that row's `canonicalSessionName`, then run this in a **new terminal on the
same host, as the same user**:
Find `logicalId: operator.agent` and use its `canonicalSessionName` in a new
terminal on the same host and account:
```sh
env -u TMUX tmux attach-session -t '=<canonicalSessionName>'
```
This shows the operator's existing conversation and lets you type your answer.
The installing agent gives the person the command with the name already filled
in; it does not attach in its own terminal. Over SSH, connect to the installation
host first. Use **Ctrl-b, then d** to leave the conversation running and detach.
If the operator has no binding or needs attention, use
[Incomplete setup and restart](#incomplete-setup-and-restart).
The installing agent fills in the actual session name. This optional manual route
is separate from the first-install desktop action above.
### Plain terminal: a new viewing session
After Yes, if neither provider is available, compose existing tmux attachments. These
commands create only a new viewing session; they do not move or recreate the
kernel's panes. Run them once on the kernel host after checking the bindings
above. For a manual install, these prompts collect the three exact session names
from that inventory. An installing agent sets the same variables from the
observed values itself:
```sh
printf 'canonicalSessionName for operator.human (TUI): '; read -r tui_session
printf 'canonicalSessionName for advisor.lead: '; read -r advisor_session
printf 'canonicalSessionName for operator.agent: '; read -r operator_session
kernel_view="openrig-kernel-$(date +%s)-$$"
kernel_pane=$(tmux new-session -d -P -F '#{pane_id}' -s "$kernel_view" -n kernel "env -u TMUX tmux attach-session -t '=$tui_session'")
advisor_pane=$(tmux split-window -h -P -F '#{pane_id}' -t "$kernel_pane" "env -u TMUX tmux attach-session -t '=$advisor_session'")
tmux split-window -h -t "$advisor_pane" "env -u TMUX tmux attach-session -t '=$operator_session'"
```
Then arrange the view and print its exact attach command:
```sh
tmux select-layout -t "$kernel_view:" even-horizontal
printf "env -u TMUX tmux attach-session -t '=%s'\n" "$kernel_view"
```
**On macOS**, the installer can open a new Terminal window with that view:
```sh
osascript - "$kernel_view" <<'APPLESCRIPT'
on run argv
tell application "Terminal"
do script "env -u TMUX tmux attach-session -t " & quoted form of ("=" & item 1 of argv)
activate
end tell
end run
APPLESCRIPT
```
**On a Linux desktop with GNOME Terminal**, use its explicit new-window action:
```sh
gnome-terminal --window -- env -u TMUX tmux attach-session -t "=$kernel_view"
```
For another terminal app, use its **New Window** action and run the attach
command printed above in that window. After Yes, the installing agent should
perform the available new-window action.
If desktop automation is unavailable or denied, say so and give the printed
command to the person for a new terminal. Never silently reuse an existing
window. Close the viewing window or use **Ctrl-b, then d** to detach; don't
quit the native agents. A standalone `rig tui` in another new terminal is also
available if the shared TUI binding is missing, with that limitation stated.
`rig terminal open saved:kernel --provider tmux --window --json` creates and opens
the three-column view itself. 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.
### Headless or SSH handoff
A provider running on the server does not establish a window on your desktop.
When there is no display, say **“No visible terminal was opened here.”** Keep
the exact attach command printed above. From a new terminal on your own
machine, connect to the same host/account used for installation, then run that
command. For example, this asks for the actual SSH destination and the viewing
session name printed above; neither value is guessed:
```sh
printf 'Existing SSH destination (user@host): '; read -r kernel_host
printf 'Viewing session name (openrig-kernel-... printed above): '; read -r kernel_view
ssh -t "$kernel_host" "env -u TMUX tmux attach-session -t '=$kernel_view'"
```
The installing agent gives you the **fully resolved** connection and attach
command from the known installation host and created viewing session. If only
an HTTP daemon address is known, ask for the SSH connection details instead of
inventing them. No new account or credential provisioning is part of this
handoff. Report which provider or fallback ran, which conversations and TUI
were visible, and any headless or unverified branch.
A provider running on the server does not establish a window on the person's
desktop. Say **“No visible terminal was opened here.”** The installing agent uses
the known SSH destination and current operator binding to provide an exact remote
attachment when requested. If only an HTTP daemon address is known, obtain the
SSH connection details instead of inventing them. No new account or credential
provisioning is part of this handoff. Report any headless or unverified branch.
### Installing-agent handoff
+84 -220
View File
@@ -2,19 +2,13 @@ import { Command } from "commander";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
import path from "node:path";
import { homedir } from "node:os";
import { shellQuote } from "../cross-host-executor.js";
import { fileURLToPath } from "node:url";
import { parseDocument } from "yaml";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
upsertCmuxSocketControlMode,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { checkClaudeAuth, checkCodexAuth } from "../provider-auth.js";
@@ -68,13 +62,13 @@ export { selectCodexProviderAuth, type CodexProviderAuth } from "../provider-aut
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
const CMUX_READY_ATTEMPTS = 5;
const CMUX_READY_DELAY_MS = 1_000;
const HERDR_INSTALL_COMMAND = "curl -fsSL https://herdr.dev/install.sh | sh";
const CORE_STEP_IDS = [
"brew",
"tmux_install",
"cmux_install",
"herdr_install",
"ghostty_install",
"claude_install",
"claude_auth",
"codex_install",
@@ -84,11 +78,9 @@ const CORE_STEP_IDS = [
];
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
const BREW_PLATFORM_SKIP = "Skipped: Homebrew setup path is only used on macOS.";
const BREW_UNAVAILABLE_SKIP = "Skipped: Homebrew not available.";
/** Steps the real run skips off macOS, with the message it gives (cmux is a macOS app installed via Homebrew). */
/** Homebrew is only used by the macOS setup path. Herdr also installs on Linux. */
const NON_DARWIN_DRY_RUN_SKIPS: Record<string, string> = {
brew: BREW_PLATFORM_SKIP,
cmux_install: BREW_UNAVAILABLE_SKIP,
};
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
// OPR.0.4.8.2 agnostic rip-out: OpenRig no longer writes ~/.claude/settings.json — the global
@@ -120,13 +112,6 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
},
];
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
scope: "global",
runtime: "cmux",
path: CMUX_SETTINGS_DISCLOSURE_PATH,
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
};
export function defaultDeps(): SetupDeps {
return {
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
@@ -146,90 +131,67 @@ function installCommand(deps: SetupDeps, cmd: string): string {
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
for (let index = 0; index < attempts; index += 1) {
/** Probe without starting a server or taking over the installing terminal. */
function findHerdrCommand(deps: SetupDeps): string | null {
const env = deps.env ?? process.env;
const installDir = env["HERDR_INSTALL_DIR"] ?? path.join(env["HOME"] ?? homedir(), ".local", "bin");
for (const command of ["herdr", shellQuote(path.join(installDir, "herdr"))]) {
try {
deps.exec("cmux capabilities --json");
return true;
} catch {
if (index < attempts - 1) {
await sleep(CMUX_READY_DELAY_MS);
}
}
deps.exec(`${command} --version`);
return command;
} catch { /* Try the installer's default path if it is not on PATH. */ }
}
return false;
return null;
}
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
if (platform !== "darwin") return false;
const settingsPath = resolveCmuxSettingsPath();
const settingsText = deps.readFile(settingsPath);
const currentMode = readCmuxSocketControlModeFromText(settingsText);
if (currentMode.error) {
return false;
function installHerdr(deps: SetupDeps, platform: NodeJS.Platform): SetupStep {
if (platform !== "darwin" && platform !== "linux") {
return { id: "herdr_install", status: "skipped", message: "Automatic herdr installation is supported on macOS and Linux." };
}
let command = findHerdrCommand(deps);
const present = command !== null;
try {
deps.mkdirp?.(path.dirname(settingsPath));
const next = upsertCmuxSocketControlMode(settingsText, "automation");
if (next.changed) {
deps.writeFile(settingsPath, next.content);
if (!command) {
installCommand(deps, HERDR_INSTALL_COMMAND);
command = findHerdrCommand(deps);
if (!command) throw new Error("The installer returned, but the herdr binary could not be verified.");
}
} catch {
return false;
return {
id: "herdr_install",
status: present ? "pass" : "applied",
message: `${present ? "Found" : "Installed"} herdr (${command}). No terminal or view was opened.`,
...(command !== "herdr" ? { fixHint: `Use ${command} when opening the view, or add its directory to your shell PATH.` } : {}),
};
} catch (err) {
return {
id: "herdr_install", status: "warn",
message: `Could not install or verify herdr: ${(err as Error).message}`,
reason: "The OpenRig view can use plain tmux without herdr. Existing cmux workflows remain available.",
fixHint: `Retry \`${HERDR_INSTALL_COMMAND}\`, or follow https://herdr.dev/docs/install/.`,
};
}
const shellReady = await waitForCmuxCapabilities(deps, 1);
if (shellReady) {
try {
deps.exec("cmux reload-config");
} catch {
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
}
return waitForCmuxCapabilities(deps, 1);
}
try {
deps.exec("open -a /Applications/cmux.app");
} catch {
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
}
return waitForCmuxCapabilities(deps);
}
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
return platform === "darwin"
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
}
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
const fetchFn = doctorDeps?.fetch;
if (!fetchFn) return "skipped";
const config = doctorDeps.configStore.resolve();
const host = config.daemon.host;
const port = config.daemon.port;
/** The installing agent asks once; only an explicit Yes installs the optional Mac terminal. */
function installGhostty(deps: SetupDeps, platform: NodeJS.Platform, choice?: boolean): SetupStep {
const id = "ghostty_install";
if (platform !== "darwin") return { id, status: "skipped", message: "The Ghostty offer is for macOS; existing Linux terminals remain available." };
if (choice === false) return { id, status: "skipped", message: "Ghostty declined (--no-ghostty). Terminal.app remains available." };
const home = (deps.env ?? process.env)["HOME"] ?? homedir();
const locations = ["/Applications/Ghostty.app", path.join(home, "Applications/Ghostty.app")];
if (locations.some(deps.exists)) return { id, status: "pass", message: "Ghostty is already installed. No terminal was opened." };
if (choice !== true) return {
id, status: "skipped",
message: "Installing agent: ask once, 'Install Ghostty for the OpenRig view?'",
fixHint: "On Yes, rerun rig setup with --ghostty, preserving earlier choices such as --no-herdr. On No, use --no-ghostty; Terminal.app remains available. The person types no command.",
};
try {
const healthRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/healthz`);
if (!healthRes.ok) return "skipped";
} catch {
return "skipped";
}
try {
const cmuxRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/api/adapters/cmux/status`);
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
const data = (await cmuxRes.json()) as { available?: boolean };
return data.available ? "available" : "unavailable";
} catch {
return "unavailable";
installCommand(deps, "brew install --cask ghostty");
if (!locations.some(deps.exists)) throw new Error("The installer returned, but Ghostty.app was not found.");
return { id, status: "applied", message: "Installed Ghostty. No terminal was opened." };
} catch (err) {
return { id, status: "fail", message: `Could not install or verify Ghostty: ${(err as Error).message}`,
fixHint: "Check Homebrew and https://ghostty.org/docs/install/binary. Terminal.app remains available; no view was opened." };
}
}
@@ -327,16 +289,22 @@ export function recordPermissionPolicyStep(deps: SetupDeps, choice: string, spec
};
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; policy?: string; specPath?: string; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; policy?: string; specPath?: string; herdr?: boolean; ghostty?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
const profile = opts.full ? "full" : "core";
const platform = deps.platform ?? process.platform;
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
const runtimeConfig = [...BASE_RUNTIME_CONFIG_DISCLOSURE];
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
const steps: SetupStep[] = [];
if (opts.dryRun) {
for (const id of stepIds) {
const platformSkip = platform !== "darwin" ? NON_DARWIN_DRY_RUN_SKIPS[id] : undefined;
const platformSkip = id === "herdr_install" && opts.herdr === false ? "Skipped: herdr installation declined (--no-herdr)."
: id === "herdr_install" && !["darwin", "linux"].includes(platform) ? "Automatic herdr installation is supported on macOS and Linux."
: id === "ghostty_install" ? (platform !== "darwin" ? "The Ghostty offer is for macOS."
: opts.ghostty === false ? "Ghostty declined (--no-ghostty)."
: opts.ghostty === true ? "Dry run: Ghostty installation would be attempted."
: "Dry run: the installing agent offers Ghostty once; only Yes installs it.")
: platform !== "darwin" ? NON_DARWIN_DRY_RUN_SKIPS[id] : undefined;
steps.push({ id, status: "skipped", message: platformSkip ?? `Dry run: ${id} would be attempted.` });
}
if (opts.policy !== undefined) {
@@ -364,7 +332,7 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
id: "brew",
status: "fail",
message: "Homebrew not found.",
reason: "Homebrew is required to install tmux and cmux on macOS.",
reason: "Homebrew is required to install tmux on macOS.",
fixHint: "Install Homebrew: https://brew.sh",
});
}
@@ -396,119 +364,12 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
// 3. cmux
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
if (await waitForCmuxCapabilities(deps, 1)) {
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
if (platform === "darwin" && socketMode.error) {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux settings file is unreadable.",
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
});
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
});
}
} else if (daemonCmuxBefore === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but the running daemon still cannot control cmux.",
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
});
} else {
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
}
} else {
try {
deps.exec("cmux --help");
// 3. Herdr is the default view provider. Leave optional cmux installations untouched.
steps.push(opts.herdr === false
? { id: "herdr_install", status: "skipped", message: "Skipped: herdr installation declined (--no-herdr)." }
: installHerdr(deps, platform));
steps.push(installGhostty(deps, platform, opts.ghostty));
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "cmux installed but control unavailable.",
reason: "Open CMUX workflows need cmux socket control to be enabled.",
fixHint: platform === "darwin"
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch {
if (!brewOk) {
steps.push({ id: "cmux_install", status: "skipped", message: BREW_UNAVAILABLE_SKIP });
} else {
try {
installCommand(deps, "brew install --cask cmux");
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "Installed cmux, but control is still unavailable.",
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch (err) {
steps.push({
id: "cmux_install",
status: "fail",
message: `Failed to install cmux: ${(err as Error).message}`,
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
});
}
}
}
}
// 4. tmux config
// 4. Claude Code runtime
let claudeInstalled = false;
try {
@@ -701,18 +562,18 @@ export function goldenPathNextSteps(): string[] {
" Started is not ready. The view may open while agents finish starting; report their actual state",
" For an existing kernel or blocked startup: rig context get reference/getting-started.md#incomplete-setup-and-restart",
" 4. Installing agent: ask 'Open the OpenRig view now?'",
" Yes: open a NEW space using installed herdr, else cmux, else the guide's exact new-terminal command",
" rig terminal open saved:kernel --provider herdr --json (TUI | advisor | operator; no YAML edit)",
" Yes: run the command below; it opens a NEW terminal tab/window and preserves this terminal",
" rig terminal open saved:kernel --window --json (TUI | advisor | operator; herdr, otherwise plain tmux)",
" rig context get reference/getting-started.md#open-the-kernel-conversations",
" No: give the command to open it later. Over SSH: give the exact connection/attach command",
" No, SSH and headless use are fine background outcomes; do not report an unseen window as opened",
" For herdr, open or attach the actual session and check the visible view; CLI success alone is not proof",
" Keep the current terminal and existing spaces intact; opening this view needs no new provider install",
" No copying or typing for the person; confirm three visible columns, not just CLI success",
" On a Mac, offer Ghostty once: on Yes run rig setup --ghostty; preserve earlier choices such as --no-herdr",
" On No use --no-ghostty; Terminal.app remains available",
" Setup installs herdr by default; use --no-herdr if the person declines. Plain tmux remains available",
" rig tui --shared is the team dashboard, not the operator's conversation",
" If the view cannot open, relay the printed operator.agent attach command unchanged, in full",
" If no command is printed, find operator.agent with rig ps --nodes --rig kernel --json",
" Use its canonicalSessionName in: env -u TMUX tmux attach-session -t '=<canonicalSessionName>'",
" Give the person that command with the name filled in, to run in a new terminal on the kernel host",
" If opening fails, inspect any new surface before retrying; report the error and finish the local action",
" Do not replace a failed desktop action with commands for the person to copy or type",
" 5. Installing agent: ask the person's goal and project folder, then hand them to the ready operator",
" Find operator.agent with rig ps --nodes --rig kernel --json; use its canonicalSessionName with rig send",
" Send the goal and folder, or have the person type them in the operator pane; show where it answers",
@@ -779,12 +640,15 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
.option("--dry-run", "Show the plan without making changes")
.option("--json", "Machine-readable JSON output")
.option("--full", "Install broader operator workstation tools")
.option("--no-herdr", "Skip the default herdr installation; the welcome view can use plain tmux")
.option("--ghostty", "Install Ghostty on macOS after the person accepts the offer")
.option("--no-ghostty", "Decline Ghostty; Terminal.app remains available on macOS")
.option("--policy <name>", `Record a deliberate permission-policy choice into an existing spec (${POLICY_CHOICES.join("|")})`)
.option("--spec <path>", "Existing rig spec (file or directory) to record the --policy choice into")
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean; policy?: string; spec?: string }) => {
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean; herdr?: boolean; ghostty?: boolean; policy?: string; spec?: string }) => {
const deps = depsOverride ?? defaultDeps();
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, policy: opts.policy, specPath: opts.spec, doctorDeps });
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, policy: opts.policy, specPath: opts.spec, herdr: opts.herdr, ghostty: opts.ghostty, doctorDeps });
if (opts.json) {
console.log(JSON.stringify({ ...result, nextSteps: goldenPathNextSteps() }, null, 2));
+13 -5
View File
@@ -21,12 +21,12 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface TerminalDeps extends StatusDeps {}
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
interface OpenViewResult {
export interface OpenViewResult {
provider: string;
ok: boolean;
opened: string[];
@@ -36,6 +36,7 @@ interface OpenViewResult {
error?: string;
code?: string;
notes?: string[];
window?: { app: string; surface: string };
}
// Opening applies pages through several bounded provider round trips; it is
@@ -67,6 +68,7 @@ function humanOpen(r: OpenViewResult): string {
? `Opened ${tiled} tile(s) in ${r.provider}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
: `No tiles opened in ${r.provider}.`,
);
if (r.window) lines.push(` Terminal: ${r.window.app} (${r.window.surface}).`);
if (r.error) lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
for (const seat of r.opened) lines.push(` ● ${seat}`);
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
@@ -103,11 +105,17 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.command("open")
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
.description("Open every live agent in the view as an interactive terminal tile")
.option("--provider <name>", "terminal provider: herdr (default) or cmux (best-effort)")
.option("--provider <name>", "herdr (default), cmux, or tmux with --window")
.option("--window", "Open a new desktop terminal tab/window; herdr if installed, otherwise plain tmux")
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { provider?: string; json?: boolean }) => {
.action(async (view: string, opts: { provider?: string; json?: boolean; window?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
if (opts.window) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps);
printOpen(opts.json ?? false, result, 200);
return;
}
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}) };
const res = await client.post<OpenViewResult>("/api/terminal/open", body, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
if (!Array.isArray(res.data?.opened)) {
+190
View File
@@ -0,0 +1,190 @@
import { execFile, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir, hostname, networkInterfaces } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import type { DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import type { OpenViewResult } from "./commands/terminal.js";
interface Pane { seat: string; label: string; paneCommand: string }
interface Preview {
planId: string;
status: { launch?: { socketPath: string; session?: string } };
composed: { opened: Pane[]; pages: Pane[][]; columns?: number; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
}
export interface WindowDeps {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
exists(file: string): boolean;
exec(file: string, args: string[], timeoutMs?: number): Promise<string>;
launch(file: string, args: string[]): Promise<void>;
sleep(ms: number): Promise<void>;
id(): string;
}
export function defaultWindowDeps(): WindowDeps {
const env = { ...process.env };
delete env["TMUX"];
const run = promisify(execFile);
return {
platform: process.platform, env, exists: existsSync,
exec: async (file, args, timeoutMs = 10_000) => (await run(file, args, { env, encoding: "utf8", timeout: timeoutMs })).stdout,
launch: (file, args) => new Promise((resolve, reject) => {
const child = spawn(file, args, { env, detached: true, stdio: "ignore" });
child.once("error", reject);
child.once("spawn", () => { child.unref(); resolve(); });
}),
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
id: () => randomUUID().slice(0, 12),
};
}
function localDaemon(url: string): boolean {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
return ["localhost", "::1", "::", "0.0.0.0", hostname()].includes(host)
|| /^127\./.test(host)
|| Object.values(networkInterfaces()).flat().some(iface => iface?.address === host);
}
async function herdrBinary(deps: WindowDeps): Promise<string | null> {
const dir = deps.env["HERDR_INSTALL_DIR"] ?? path.join(deps.env["HOME"] ?? homedir(), ".local", "bin");
for (const candidate of ["herdr", path.join(dir, "herdr")]) {
try {
await deps.exec(candidate, ["--version"]);
const binary = candidate === "herdr" ? (await deps.exec("/bin/sh", ["-c", "command -v herdr"])).trim() : candidate;
if (path.isAbsolute(binary)) return binary;
} catch { /* The installer path also works when it is absent from PATH. */ }
}
return null;
}
/** New surfaces only. No System Events keystrokes or existing-terminal input. */
async function windowLauncher(deps: WindowDeps): Promise<(command: string) => Promise<{ app: string; surface: string }>> {
if (deps.platform === "darwin") {
const app = ["/Applications/Ghostty.app", path.join(deps.env["HOME"] ?? homedir(), "Applications/Ghostty.app")].find(deps.exists);
let ghostty = false;
if (app) {
try {
const version = await deps.exec("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", path.join(app, "Contents/Info.plist")]);
const [major, minor] = version.trim().split(".").map(Number);
ghostty = major! > 1 || (major === 1 && minor! >= 3);
} catch { /* Older/non-scriptable Ghostty uses the system terminal. */ }
}
const script = ghostty ? `on run argv
tell application "Ghostty"
set cfg to new surface configuration
set command of cfg to item 1 of argv
if (count of windows) > 0 then
set newTab to new tab in front window with configuration cfg
select tab newTab
focus (focused terminal of newTab)
activate window front window
return "tab"
else
set newWindow to new window with configuration cfg
activate window newWindow
return "window"
end if
end tell
end run` : `on run argv
tell application "Terminal"
do script (item 1 of argv)
activate
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();
return { app: ghostty ? "Ghostty" : "Terminal", surface };
};
}
if (deps.platform !== "linux" || (!deps.env["DISPLAY"] && !deps.env["WAYLAND_DISPLAY"])) {
throw new Error("No local desktop display is available; no terminal window was opened. Run this command on the daemon's desktop.");
}
for (const app of ["ghostty", "x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
try { await deps.exec("/bin/sh", ["-c", `command -v ${app}`]); } catch { continue; }
return async command => {
const args = app === "gnome-terminal" ? ["--window", "--", "/bin/sh", "-c", command] : ["-e", "/bin/sh", "-c", command];
await deps.launch(app, args);
return { app, surface: "window-requested" };
};
}
throw new Error("No supported desktop terminal was found; install Ghostty or a system terminal and retry.");
}
function failure(provider: string, error: string): OpenViewResult {
return { provider, ok: false, opened: [], absent: [], degraded: [], pages: 0, error, code: "terminal_window_failed" };
}
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps()): Promise<OpenViewResult> {
let provider = requestedProvider ?? "herdr";
let window: { app: string; surface: string } | undefined;
let viewer: string | undefined;
try {
if (!localDaemon(client.baseUrl)) throw new Error("--window 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);
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`);
if (preview.status >= 400 || !("composed" in preview.data)) {
return failure(provider, (preview.data as OpenViewResult).error ?? "Could not compose the requested view.");
}
const { composed, planId } = preview.data;
if (!composed.opened.length) return { ...failure(provider, "No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
if (herdr) {
const endpoint = preview.data.status.launch;
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.
window = await launchWindow(`env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)} ${shellQuote(herdr)}`);
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");
if (status.data.providers?.[0]?.liveness.alive) { alive = true; break; }
await deps.sleep(250);
}
if (!alive) throw new Error("The terminal was requested, but herdr's control socket did not become ready. Inspect the new terminal before retrying.");
const result = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr", expectedPlan: planId }, { timeoutMs: 45_000 });
if (result.status >= 400) return { ...failure(provider, result.data.error ?? `The daemon refused the view (HTTP ${result.status}).`), window, 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 ?? []), "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
}
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()}`;
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();
// 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");
const sizing = (await deps.exec(tmux, ["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await deps.exec(tmux, ["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", first.label]);
for (const item of page.slice(1)) {
pane = (await deps.exec(tmux, ["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", item.label]);
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
}
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await deps.exec(tmux, ["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
}
await deps.exec(tmux, ["select-window", "-t", `${viewer}:view-1`]);
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.`, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
} catch (err) {
return { ...failure(provider, (err as Error).message), ...(window ? { window } : {}), ...(viewer ? { notes: [`Viewing session ${viewer} may exist. Inspect it before retrying; no existing conversation was replaced.`] } : {}) };
}
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { runSetup, type SetupDeps } from "../src/commands/setup.js";
function fixture(installed = false) {
let present = installed;
const exec = vi.fn((cmd: string, _opts?: { timeoutMs?: number }) => {
if (cmd.endsWith("herdr --version") || cmd.endsWith("herdr' --version")) {
if (!present) throw new Error("missing herdr");
return "herdr 0.9.3";
}
if (cmd === "curl -fsSL https://herdr.dev/install.sh | sh") { present = true; return "installed"; }
if (cmd === "tmux -V") return "tmux 3.4";
return "available";
});
const deps: SetupDeps = { platform: "darwin", env: { HOME: "/fixture" }, exec, exists: () => false, readFile: () => null, writeFile: vi.fn(), mkdirp: vi.fn() };
return { deps, exec };
}
describe("setup Herdr default", () => {
it.each(["darwin", "linux"] as const)("installs and verifies missing Herdr on %s without opening a view", async platform => {
const f = fixture(); f.deps.platform = platform;
const result = await runSetup(f.deps, {});
expect(result.steps).toContainEqual(expect.objectContaining({ id: "herdr_install", status: "applied" }));
expect(f.exec).toHaveBeenCalledWith("curl -fsSL https://herdr.dev/install.sh | sh", { timeoutMs: 300_000 });
expect(f.exec.mock.calls.some(([cmd]) => /osascript|open -a|cmux|terminal open/.test(cmd))).toBe(false);
expect(vi.mocked(f.deps.writeFile).mock.calls.some(([file]) => file.includes("cmux"))).toBe(false);
});
it("reuses an installed binary without installing again", async () => {
const f = fixture(true);
const result = await runSetup(f.deps, {});
expect(result.steps).toContainEqual(expect.objectContaining({ id: "herdr_install", status: "pass" }));
expect(f.exec.mock.calls.some(([cmd]) => cmd.includes("herdr.dev/install"))).toBe(false);
});
it("honors decline in both real and dry-run modes without any Herdr probe", async () => {
for (const dryRun of [false, true]) {
const f = fixture();
const result = await runSetup(f.deps, { herdr: false, dryRun });
expect(result.steps).toContainEqual(expect.objectContaining({ id: "herdr_install", status: "skipped", message: expect.stringContaining("declined") }));
expect(f.exec.mock.calls.some(([cmd]) => cmd.includes("herdr"))).toBe(false);
}
});
it.each([
["darwin", "install"], ["linux", "install"],
["darwin", "verification"], ["linux", "verification"],
] as const)("keeps setup ready after optional Herdr failure on %s during %s", async (platform, failure) => {
const f = fixture();
f.deps.platform = platform;
const original = f.deps.exec;
f.deps.exec = (cmd, opts) => {
if (cmd.includes("herdr.dev/install")) {
if (failure === "install") throw new Error("install timeout");
return "installer returned without a usable binary";
}
return original(cmd, opts);
};
const result = await runSetup(f.deps, {});
expect(result.ready).toBe(true);
expect(result.steps).toContainEqual(expect.objectContaining({
id: "herdr_install", status: "warn",
message: expect.stringContaining(failure === "install" ? "install timeout" : "could not be verified"),
reason: expect.stringContaining("plain tmux"), fixHint: expect.stringContaining("herdr.dev"),
}));
expect(result.steps).toContainEqual(expect.objectContaining({ id: "claude_auth", status: "pass" }));
});
});
describe("setup Ghostty offer", () => {
it("offers once without installing, and installs only after the accepted choice", async () => {
const f = fixture(true);
let installed = false;
f.deps.exists = file => installed && file === "/Applications/Ghostty.app";
const original = f.deps.exec;
f.deps.exec = vi.fn((cmd, opts) => {
if (cmd === "brew install --cask ghostty") installed = true;
return original(cmd, opts);
});
const offered = await runSetup(f.deps, {});
expect(offered.steps).toContainEqual(expect.objectContaining({ id: "ghostty_install", status: "skipped", message: expect.stringContaining("ask once") }));
expect(f.deps.exec).not.toHaveBeenCalledWith("brew install --cask ghostty", expect.anything());
const accepted = await runSetup(f.deps, { ghostty: true });
expect(accepted.steps).toContainEqual(expect.objectContaining({ id: "ghostty_install", status: "applied" }));
expect(f.deps.exec).toHaveBeenCalledWith("brew install --cask ghostty", { timeoutMs: 300_000 });
const existing = await runSetup(f.deps, {});
expect(existing.steps).toContainEqual(expect.objectContaining({ id: "ghostty_install", status: "pass" }));
expect(vi.mocked(f.deps.exec).mock.calls.filter(([cmd]) => cmd.includes("--cask ghostty"))).toHaveLength(1);
});
it.each(["declined", "dry-run", "linux"])("does not install or launch Ghostty for %s", async mode => {
const f = fixture(true);
if (mode === "linux") f.deps.platform = "linux";
const result = await runSetup(f.deps, { ghostty: mode !== "declined", dryRun: mode === "dry-run" });
expect(result.steps).toContainEqual(expect.objectContaining({ id: "ghostty_install", status: "skipped" }));
expect(f.exec.mock.calls.some(([cmd]) => /ghostty|osascript|open -a/.test(cmd))).toBe(false);
});
it("does not report an unverified installation as successful", async () => {
const f = fixture(true);
const result = await runSetup(f.deps, { ghostty: true });
expect(result.ready).toBe(false);
expect(result.steps).toContainEqual(expect.objectContaining({ id: "ghostty_install", status: "fail", message: expect.stringContaining("was not found") }));
});
});
+20 -2
View File
@@ -37,9 +37,13 @@ function fixture(failed: boolean): SetupDeps {
function expectConversationRoute(text: string): void {
expect(text).toContain("Open the OpenRig view now?");
expect(text).toContain("On a Mac, offer Ghostty once");
expect(text).toContain("rig setup --ghostty");
expect(text).toContain("--no-ghostty");
expect(text).toContain("preserve earlier choices such as --no-herdr");
expect(text).toContain("rig terminal open saved:kernel");
expect(text).toContain("relay the printed operator.agent attach command unchanged, in full");
expect(text).toContain("env -u TMUX tmux attach-session");
expect(text).toContain("rig terminal open saved:kernel --window --json");
expect(text).toContain("No copying or typing for the person");
expect(text).toContain("then hand them to the ready operator");
expect(text).toContain("Do not implement the person's project yourself");
expect(text).toContain("Started is not ready");
@@ -94,3 +98,17 @@ describe("setup conversation handoff output", () => {
}
}
});
it.each([
[[], "offers Ghostty once"],
[["--ghostty"], "installation would be attempted"],
[["--no-ghostty"], "declined"],
] as const)("keeps the Ghostty choice in dry-run JSON: %j", async (flags, expected) => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.map(String).join(" ")));
const program = new Command().exitOverride().addCommand(setupCommand(fixture(false)));
await program.parseAsync(["node", "rig", "setup", "--dry-run", "--json", ...flags]);
const result = JSON.parse(logs[0]!) as SetupResult;
expect(result.steps.find(step => step.id === "ghostty_install")?.message).toContain(expected);
});
+22 -239
View File
@@ -4,13 +4,11 @@ import { Command } from "commander";
import { parse as parseYaml } from "yaml";
import { setupCommand, runSetup, goldenPathNextSteps, permissionPolicyMenuLines, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import type { DoctorDeps } from "../src/commands/doctor.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
function makeDeps(overrides?: Partial<SetupDeps>): SetupDeps {
return {
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
@@ -48,9 +46,8 @@ function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCod
}
function expectRuntimeConfigDisclosure(result: SetupResult): void {
// OPR.0.4.8.2 agnostic rip-out: the ~/.claude/settings.json disclosure (the global allow-list,
// C2) is REMOVED — OpenRig no longer writes that file. Disclosure drops from 6 to 5 entries.
expect(result.runtimeConfig).toHaveLength(5);
// Setup leaves cmux configuration alone; only managed runtime disclosures remain.
expect(result.runtimeConfig).toHaveLength(4);
expect(result.runtimeConfig.find((d) => d.path === "~/.claude/settings.json")).toBeUndefined();
expect(result.runtimeConfig).toEqual(expect.arrayContaining([
{
@@ -77,12 +74,6 @@ function expectRuntimeConfigDisclosure(result: SetupResult): void {
path: "$CODEX_HOME/config.toml (default: ~/.codex/config.toml)",
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
},
{
scope: "global",
runtime: "cmux",
path: "~/.config/cmux/settings.json",
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
},
]));
}
@@ -108,7 +99,7 @@ describe("rig setup", () => {
const stepIds = result.steps.map((s) => s.id);
expect(stepIds).toContain("brew");
expect(stepIds).toContain("tmux_install");
expect(stepIds).toContain("cmux_install");
expect(stepIds).toContain("herdr_install");
expect(stepIds).toContain("claude_install");
expect(stepIds).toContain("claude_auth");
expect(stepIds).toContain("codex_install");
@@ -138,7 +129,7 @@ describe("rig setup", () => {
// Core steps still present
expect(stepIds).toContain("brew");
expect(stepIds).toContain("tmux_install");
expect(stepIds).toContain("cmux_install");
expect(stepIds).toContain("herdr_install");
expect(stepIds).toContain("claude_install");
expect(stepIds).toContain("claude_auth");
expect(stepIds).toContain("codex_install");
@@ -176,7 +167,7 @@ describe("rig setup", () => {
throw new Error(`not found: ${cmd}`);
} }), {});
for (const id of ["brew", "cmux_install"]) {
for (const id of ["brew"]) {
const dryStep = dry.steps.find((s) => s.id === id);
expect(dryStep?.status).toBe("skipped");
expect(dryStep?.message).toBe(real.steps.find((s) => s.id === id)?.message);
@@ -185,10 +176,10 @@ describe("rig setup", () => {
expect(dry.steps.find((s) => s.id === "tmux_install")?.message).toBe("Dry run: tmux_install would be attempted.");
});
it("--dry-run on darwin still reports brew and cmux_install as would be attempted", async () => {
it("--dry-run on darwin still reports brew and herdr_install as would be attempted", async () => {
const dry = await runSetup(makeDeps({ platform: "darwin" }), { dryRun: true });
expect(dry.steps.find((s) => s.id === "brew")).toEqual({ id: "brew", status: "skipped", message: "Dry run: brew would be attempted." });
expect(dry.steps.find((s) => s.id === "cmux_install")).toEqual({ id: "cmux_install", status: "skipped", message: "Dry run: cmux_install would be attempted." });
expect(dry.steps.find((s) => s.id === "herdr_install")).toEqual({ id: "herdr_install", status: "skipped", message: "Dry run: herdr_install would be attempted." });
});
it("core profile execution with all tools present returns pass/applied steps and ready=true", async () => {
@@ -205,8 +196,8 @@ describe("rig setup", () => {
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("pass");
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
const herdr = result.steps.find((s) => s.id === "herdr_install");
expect(herdr?.status).toBe("pass");
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
expect(claudeInstall?.status).toBe("pass");
@@ -275,6 +266,7 @@ describe("rig setup", () => {
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
@@ -321,6 +313,7 @@ describe("rig setup", () => {
let claudeInstalled = false;
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
@@ -351,6 +344,7 @@ describe("rig setup", () => {
it("fails setup honestly when Codex is installed but not logged in", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
@@ -373,6 +367,7 @@ describe("rig setup", () => {
const deps = makeDeps({
platform: "linux",
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "cmux capabilities --json") throw new Error("not found");
@@ -395,6 +390,7 @@ describe("rig setup", () => {
it("brew failure does not crash — later brew-dependent steps are skipped honestly", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") throw new Error("command not found: brew");
if (cmd === "tmux -V") throw new Error("command not found: tmux");
if (cmd === "tmux list-sessions") throw new Error("command not found: tmux");
@@ -413,8 +409,8 @@ describe("rig setup", () => {
const tmux = result.steps.find((s) => s.id === "tmux_install");
expect(tmux?.status).toBe("skipped");
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("skipped");
const herdr = result.steps.find((s) => s.id === "herdr_install");
expect(herdr?.status).toBe("pass");
// verify step should reflect the failures
const verify = result.steps.find((s) => s.id === "verify");
@@ -512,6 +508,7 @@ describe("rig setup", () => {
it("tmux install failure returns structured fail, does not crash setup", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") throw new Error("not found");
if (cmd === "brew install tmux") throw new Error("brew install failed");
@@ -526,224 +523,9 @@ describe("rig setup", () => {
expect(result.ready).toBe(false);
// Other steps still attempted
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux).toBeDefined();
expect(cmux?.status).toBe("applied");
});
it("enables cmux automation mode on macOS when cmux is installed but not yet controllable", async () => {
let cmuxReady = false;
const seen: string[] = [];
const writeSpy = vi.fn();
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
writeFile: writeSpy,
exec: (cmd: string) => {
seen.push(cmd);
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
if (cmuxReady) return '{"methods":["surface.focus"]}\n';
throw new Error("socket not ready");
}
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "open -a /Applications/cmux.app") {
cmuxReady = true;
return "";
}
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
expect(cmux?.message).toContain("Enabled cmux socket control");
expect(seen).toContain("open -a /Applications/cmux.app");
expect(writeSpy).toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.stringContaining("\"socketControlMode\": \"automation\""),
);
expect(result.ready).toBe(true);
});
it("normalizes restrictive cmux socket control on macOS even when shell cmux already works", async () => {
const seen: string[] = [];
const writeSpy = vi.fn();
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => false,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true };
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) };
throw new Error(`unexpected fetch ${url}`);
},
};
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
writeFile: writeSpy,
exec: (cmd: string) => {
seen.push(cmd);
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "cmux reload-config") return "";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, { doctorDeps });
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("applied");
expect(cmux?.message).toContain("automation");
expect(seen).toContain("cmux reload-config");
expect(writeSpy).toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.stringContaining("\"socketControlMode\": \"automation\""),
);
expect(result.ready).toBe(true);
});
it("fails honestly when cmux stays uncontrollable after automatic macOS setup", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") throw new Error("broken pipe");
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "open -a /Applications/cmux.app") return "";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
return "";
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("cmux installed but control unavailable");
expect(result.ready).toBe(false);
});
it("does not rewrite compatible password mode when shell cmux already works", async () => {
const writeSpy = vi.fn();
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
writeFile: writeSpy,
exec: (cmd: string) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("pass");
expect(writeSpy).not.toHaveBeenCalledWith(
CMUX_SETTINGS_PATH,
expect.any(String),
);
expect(result.ready).toBe(true);
});
it("fails setup when shell cmux works but the running daemon still cannot control cmux", async () => {
const doctorDeps: DoctorDeps = {
exists: () => true,
baseDir: "/install/cli/dist",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => false,
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true };
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) };
throw new Error(`unexpected fetch ${url}`);
},
};
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
});
const result = await runSetup(deps, { doctorDeps });
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("running daemon still cannot control cmux");
expect(result.ready).toBe(false);
});
it("gives fresh install commands a longer timeout budget and fails cmux honestly when install still errors", async () => {
const seenTimeouts = new Map<string, number | undefined>();
const deps = makeDeps({
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("error connecting to /tmp/tmux (No such file or directory)");
if (cmd === "cmux capabilities --json") throw new Error("command not found: cmux");
if (cmd === "cmux --help") throw new Error("command not found: cmux");
if (cmd === "brew install --cask cmux") {
seenTimeouts.set(cmd, opts?.timeoutMs);
throw new Error("spawnSync /bin/sh ETIMEDOUT");
}
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
if (cmd === "claude auth status") return "Authenticated\n";
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
if (cmd === "codex login status") return "Logged in\n";
throw new Error(`unexpected: ${cmd}`);
},
});
const result = await runSetup(deps, {});
expect(seenTimeouts.get("brew install --cask cmux")).toBe(300000);
const cmux = result.steps.find((s) => s.id === "cmux_install");
expect(cmux?.status).toBe("fail");
expect(cmux?.message).toContain("ETIMEDOUT");
expect(result.ready).toBe(false);
const herdr = result.steps.find((s) => s.id === "herdr_install");
expect(herdr).toBeDefined();
expect(herdr?.status).toBe("pass");
});
it("uses the extended timeout budget for npm runtime installs", async () => {
@@ -752,6 +534,7 @@ describe("rig setup", () => {
let codexInstalled = false;
const deps = makeDeps({
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
if (cmd === "herdr --version") return "herdr 0.9.3\n";
if (cmd === "brew --version") return "Homebrew 4.0\n";
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
@@ -0,0 +1,107 @@
import { afterEach, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { DaemonClient } from "../src/client.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.js";
import { composeView } from "../../daemon/src/domain/terminal/view-composer.js";
// Real isolated tmux servers; the desktop launcher alone is inert.
const sockets: string[] = [];
const dirs: string[] = [];
const env = { ...process.env }; delete env["TMUX"];
function tmux(socket: string, args: string[]): string {
return execFileSync("tmux", ["-L", socket, "-f", "/dev/null", ...args], { encoding: "utf8", env, timeout: 5000 });
}
afterEach(() => {
for (const socket of sockets.splice(0)) {
try { tmux(socket, ["kill-server"]); } catch { /* Already exited after a failing setup. */ }
}
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it.each([
["shared", "80x24"], ["shared", "120x40"], ["separate", "120x40"],
])("opens nineteen composed seats on %s servers at configured size %s", async (placement, size) => {
const id = randomUUID().slice(0, 12);
const source = `ws-${id}`;
const viewer = placement === "shared" ? source : `wv-${id}`;
sockets.push(...new Set([source, viewer]));
const dir = mkdtempSync(path.join(tmpdir(), "window-test-"));
dirs.push(dir);
const wrapper = path.join(dir, "tmux");
writeFileSync(wrapper, `#!/bin/sh\nexec tmux -L ${source} -f /dev/null "$@"\n`, { mode: 0o755 });
const names = Array.from({ length: 19 }, (_, index) => `source${index}`);
for (const name of names) tmux(source, ["new-session", "-d", "-s", name, "sleep 120"]);
if (viewer !== source) tmux(viewer, ["new-session", "-d", "-s", "viewer-fixture", "sleep 120"]);
tmux(viewer, ["set-option", "-g", "default-size", size]);
tmux(viewer, ["set-option", "-gw", "window-size", "latest"]);
const snapshot = () => names.map(name => tmux(source, ["list-panes", "-t", `=${name}`, "-F", "#{pane_id}|#{pane_pid}"]));
const original = snapshot();
const composed = composeView("saved:fixture", names.map(seat => ({
seat, label: seat, tmuxSession: seat, host: null, readOnly: true, alive: true,
})), { resolveHost: () => null, panesPerPage: 16, localTmux: wrapper });
expect(composed.pages.map(page => page.length)).toEqual([16, 3]);
const client = { baseUrl: "http://localhost:7433", get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed } }) } as unknown as DaemonClient;
const desktop = vi.fn(async () => "window");
const beforeSplits: string[] = [];
const deps: WindowDeps = {
platform: "darwin", env: { HOME: dir }, exists: () => false,
exec: async (file, args) => {
if (file === "/bin/sh") return "tmux";
if (file === "/usr/bin/osascript") return desktop();
if (file === "tmux") {
if (args[0] === "split-window") {
beforeSplits.push(tmux(viewer, ["display-message", "-p", "-t", args[args.indexOf("-t") + 1]!, "#{window_width}x#{window_height}"]).trim());
}
return tmux(viewer, args);
}
throw new Error(`unexpected executable ${file}`);
},
launch: vi.fn(async () => {}), sleep: async () => {}, id: () => id,
};
const result = await openTerminalWindow(client, "saved:fixture", "tmux", deps);
expect(snapshot()).toEqual(original);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, opened: names, pages: 2 });
expect(desktop).toHaveBeenCalledTimes(1);
expect(beforeSplits).toEqual(Array(17).fill(size));
await new Promise(resolve => setTimeout(resolve, 150));
expect(tmux(viewer, ["list-panes", "-s", "-t", `=openrig-view-${id}`, "-F", "#{pane_dead}"]).trim().split("\n")).toEqual(Array(19).fill("0"));
for (const page of [1, 2]) {
expect(tmux(viewer, ["show-options", "-Awv", "-t", `openrig-view-${id}:view-${page}`, "window-size"]).trim()).toBe("latest");
}
}, 40_000);
it("opens three real viewer panes while original sessions keep the same pane and process", async () => {
// Short names also fit macOS Unix sockets under CI's existing TMPDIR.
const source = `ws-${randomUUID().slice(0, 12)}`;
const viewer = `wv-${randomUUID().slice(0, 12)}`;
sockets.push(source, viewer);
const names = ["tui", "advisor", "operator"];
for (const name of names) tmux(source, ["new-session", "-d", "-s", name, "sleep 60"]);
const original = tmux(source, ["list-panes", "-a", "-F", "#{session_name} #{pane_id} #{pane_pid}"]);
const panes = names.map(seat => ({ seat, label: seat, paneCommand: `tmux -L ${source} attach-session -t '=${seat}'` }));
const client = { baseUrl: "http://localhost:7433", get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed: { opened: panes, pages: [panes], columns: 3, absent: [], degraded: [] } } }) } as unknown as DaemonClient;
const desktop = vi.fn(async () => "window");
const deps: WindowDeps = {
platform: "darwin", env: { 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: () => "owned-view",
};
const result = await openTerminalWindow(client, "saved:kernel", "tmux", deps);
expect(result).toMatchObject({ ok: true, opened: names });
expect(desktop).toHaveBeenCalledTimes(1);
const actual = tmux(viewer, ["list-panes", "-t", "openrig-view-owned-view:view-1", "-F", "#{pane_title}|#{pane_top}|#{pane_left}|#{pane_dead}"]).trim().split("\n").map(line => line.split("|"));
expect(actual.map(row => row[0])).toEqual(names);
expect(actual.map(row => row[1])).toEqual(["0", "0", "0"]);
expect(actual.map(row => Number(row[2]))).toEqual([...actual.map(row => Number(row[2]))].sort((a, b) => a - b));
expect(actual.every(row => row[3] === "0")).toBe(true);
expect(tmux(source, ["list-panes", "-a", "-F", "#{session_name} #{pane_id} #{pane_pid}"])).toBe(original);
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.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 = { 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 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;
const exec = vi.fn(async (file: string, args: string[]) => {
if (args.includes("--version")) {
if (options.herdr === false) throw new Error("not installed");
return "herdr 0.9.3";
}
if (file === "/usr/libexec/PlistBuddy") return options.ghostty ?? "1.2.0";
if (file === "/usr/bin/osascript") return options.ghostty === "1.3.0" ? "tab" : "window";
if (file === "/bin/sh") return args[1] === "command -v herdr" ? "/fixture/bin/herdr" : "/fixture/bin/tmux";
if (args.at(-1) === "default-size") return "120x40";
if (args[0] === "show-options" && args.at(-1) === "window-size") return "latest";
if (args.includes("#{pane_id}")) return `%${++pane}`;
return "";
});
const deps: WindowDeps = { platform: "darwin", env: { HOME: "/fixture", HERDR_SESSION: "wrong-session", HERDR_SOCKET_PATH: "/wrong.sock" }, exists: () => !!options.ghostty, exec, launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "owned-test" };
return { client, deps, get, post, exec, preview };
}
describe("desktop terminal view", () => {
it("opens a new Ghostty tab and applies the same daemon plan and Herdr endpoint", async () => {
const f = fixture({ ghostty: "1.3.0" });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result).toMatchObject({ ok: true, opened: ["tui", "advisor", "operator"], window: { app: "Ghostty", surface: "tab" } });
const launch = f.exec.mock.calls.find(([file]) => file === "/usr/bin/osascript")!;
expect(launch[1][1]).toContain("new tab in front window");
expect(launch[1][1]).toContain("new window with configuration");
expect(launch[1][2]).toContain("HERDR_SOCKET_PATH='/daemon home/herdr.sock'");
expect(launch[1][2]).not.toContain("--session");
expect(launch[1][2]).toContain("-u HERDR_SESSION");
expect(launch[1][2]).not.toContain("wrong-session");
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "saved:kernel", provider: "herdr", expectedPlan: "bound-plan" }, { timeoutMs: 45_000 });
});
it("uses a new system Terminal window without requiring an existing terminal", async () => {
const f = fixture();
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.window).toEqual({ app: "Terminal", surface: "window" });
const script = f.exec.mock.calls.find(([file]) => file === "/usr/bin/osascript")![1][1];
expect(script).toContain('tell application "Terminal"');
expect(script).toContain("do script (item 1 of argv)");
expect(script).not.toContain("in front window");
});
it("creates only a new tmux viewer with the composed three-column ordering", async () => {
const f = fixture({ herdr: false });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result).toMatchObject({ ok: true, provider: "tmux", opened: ["tui", "advisor", "operator"] });
const tmux = f.exec.mock.calls.filter(([file]) => file === "/fixture/bin/tmux").map(([,args]) => args);
expect(tmux.filter(args => args[0] === "new-session")).toHaveLength(1);
expect(tmux.filter(args => args[0] === "split-window")).toHaveLength(2);
expect(tmux.filter(args => args[0] === "select-pane").map(args => args.at(-1))).toEqual(["tui", "advisor", "operator"]);
expect(tmux).toContainEqual(["select-layout", "-t", "openrig-view-owned-test:view-1", "even-horizontal"]);
expect(tmux.flat().join(" ")).not.toMatch(/kill|respawn|send-keys/);
expect(f.post).not.toHaveBeenCalled();
});
it("names the setup next step when tmux lookup exits nonzero", async () => {
const f = fixture({ herdr: false });
const original = f.deps.exec;
f.deps.exec = vi.fn(async (file, args) => {
if (file === "/bin/sh" && args[1] === "command -v tmux") throw new Error("Command failed: /bin/sh -c command -v tmux");
return original(file, args);
});
expect(await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps)).toMatchObject({ ok: false, error: "tmux is unavailable; run rig setup first." });
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript" || file === "/fixture/bin/tmux")).toBe(false);
expect(f.post).not.toHaveBeenCalled();
});
it.each(["remote", "headless", "empty"])("%s makes no window or tmux mutations", async reason => {
const f = fixture({ empty: reason === "empty" });
if (reason === "remote") Object.assign(f.client, { baseUrl: "http://192.0.2.2:7433" });
if (reason === "headless") Object.assign(f.deps, { platform: "linux", env: {} });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.ok).toBe(false);
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript" || file === "/fixture/bin/tmux")).toBe(false);
expect(f.post).not.toHaveBeenCalled();
});
it("returns Automation denial without replaying in another terminal or applying a layout", async () => {
const f = fixture();
const original = f.deps.exec;
f.deps.exec = vi.fn(async (file, args) => {
if (file === "/usr/bin/osascript") throw new Error("Automation denied");
return original(file, args);
});
expect(await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps)).toMatchObject({ ok: false, error: "Automation denied", opened: [] });
expect(vi.mocked(f.deps.exec).mock.calls.filter(([file]) => file === "/usr/bin/osascript")).toHaveLength(1);
expect(f.post).not.toHaveBeenCalled();
});
it.each([409, 503])("preserves HTTP %s refusal after a window opens", async refusal => {
const f = fixture({ refusal });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result).toMatchObject({ ok: false, error: "view changed", opened: [], window: { app: "Terminal" } });
expect(f.post).toHaveBeenCalledTimes(1);
});
it("does not apply a layout when the launched socket never becomes ready", async () => {
const f = fixture({ alive: false });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.ok).toBe(false);
expect(result.error).toContain("Inspect the new terminal before retrying");
expect(f.deps.sleep).toHaveBeenCalledTimes(20);
expect(f.post).not.toHaveBeenCalled();
});
it("explicit tmux choice bypasses Herdr and supports a Linux desktop launcher", async () => {
const f = fixture();
Object.assign(f.deps, { platform: "linux", env: { DISPLAY: ":fixture" } });
const result = await openTerminalWindow(f.client, "saved:kernel", "tmux", f.deps);
expect(result).toMatchObject({ ok: true, provider: "tmux", window: { app: "ghostty", surface: "window-requested" } });
expect(f.exec.mock.calls.some(([,args]) => args.includes("--version"))).toBe(false);
expect(f.deps.launch).toHaveBeenCalledTimes(1);
});
});
+13
View File
@@ -66,6 +66,19 @@ describe("rig terminal CLI", () => {
expect(cmd!.commands.map((c) => c.name()).sort()).toEqual(["open", "status", "views"]);
});
it("--window uses local desktop handling and reports a headless refusal without a provider POST", async () => {
const { deps, calls } = makeDeps();
const factory = deps.clientFactory;
deps.clientFactory = url => Object.assign(factory(url), { baseUrl: "http://localhost:7433" });
deps.windowDeps = { platform: "linux", env: {}, exists: () => false, exec: vi.fn(), launch: vi.fn(), sleep: vi.fn(), id: () => "unused" };
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "open", "saved:kernel", "--window", "--json"]);
expect(process.exitCode).toBe(1);
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: false, opened: [], code: "terminal_window_failed" });
expect(calls).toHaveLength(0);
});
it("open POSTs /api/terminal/open with view + provider", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"]) } },
@@ -305,6 +305,7 @@ export function extractWorkspaceId(result: HerdrResult): string | null {
export interface HerdrAdapterDeps {
transportFactory: HerdrTransportFactory;
launch?: ProviderStatus["launch"];
/**
* Mint a fresh launch token per `openView` so a relaunch creates a new tab
* (BR-5). Injectable for deterministic tests. Default: a per-instance
@@ -360,6 +361,7 @@ export class HerdrAdapter implements TerminalProvider {
const probe = await this.transport.probe();
return {
provider: this.name,
...(this.deps.launch ? { launch: this.deps.launch } : {}),
available: probe.alive,
...(probe.version ? { version: probe.version } : {}),
// The socket answering ping IS the capability surface: layout.apply is
@@ -369,7 +371,7 @@ export class HerdrAdapter implements TerminalProvider {
};
} catch {
// An unreachable socket (herdr not running) = honestly unavailable.
return { provider: this.name, available: false, capabilities: {} };
return { provider: this.name, available: false, capabilities: {}, ...(this.deps.launch ? { launch: this.deps.launch } : {}) };
}
}
@@ -72,6 +72,8 @@ export interface ProviderStatus {
/** Provider version when the probe could determine it; omitted when unknown. */
version?: string;
capabilities: Record<string, boolean>;
/** Herdr endpoint the local desktop client must attach to. */
launch?: { socketPath: string; session?: string };
}
/** Liveness of the provider surface itself (herdr: `herdr status`; NOT a daemon ping). */
+4 -2
View File
@@ -1772,7 +1772,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
{
const { TerminalService } = await import("./domain/terminal/terminal-service.js");
const { HerdrAdapter } = await import("./domain/terminal/herdr-adapter.js");
const { createHerdrSocketRpc, createHerdrSocketTransport } = await import(
const { createHerdrSocketRpc, createHerdrSocketTransport, resolveHerdrSocketPath } = await import(
"./domain/terminal/herdr-transport.js"
);
const { CmuxProviderAdapter } = await import("./domain/terminal/cmux-provider-adapter.js");
@@ -1800,9 +1800,11 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
logicalId: e.logicalId,
});
const herdrSocketPath = resolveHerdrSocketPath();
const herdrProvider = new HerdrAdapter({
// FB4: herdr speaks its unix control socket (there is no `layout` CLI).
transportFactory: createHerdrSocketTransport(createHerdrSocketRpc()),
transportFactory: createHerdrSocketTransport(createHerdrSocketRpc(herdrSocketPath)),
launch: { socketPath: herdrSocketPath, ...(process.env["HERDR_SESSION"] ? { session: process.env["HERDR_SESSION"] } : {}) },
});
const cmuxProvider = new CmuxProviderAdapter({
cmuxAdapter,
@@ -300,3 +300,13 @@ describe("S08 — cmux is unchanged", () => {
expect(planHerdrLayout(view, "t").workspaceLabel).toBe("mission:4.6");
});
});
describe("desktop Herdr endpoint", () => {
it.each([false, true])("retains the configured launch endpoint when socket alive=%s", async alive => {
const { transport } = herdrTransport();
transport.probe = async () => ({ alive });
const launch = { socketPath: "/fixture/daemon/herdr.sock", session: "fixture-daemon" };
const adapter = new HerdrAdapter({ transportFactory: () => transport, launch });
expect(await adapter.status()).toMatchObject({ available: alive, launch });
});
});