Compare commits

..
Author SHA1 Message Date
review50-r3andClaude Opus 5.5 8b180689e7 docs: the capability map teaches 0.7.2
The capability map now covers what 0.7.2 ships: moving a live seat to the
other runtime, a seat's own Claude or Codex account, moving one seat to
another login, reading a labelled Claude token's usage, per-member Claude
tool rules, stream reader actions (subscriptions stay pull-only), releasing a
row parked on a typed blocker, choice-prompt escalation, the owed restore
stage, project-aware queue whoami, cross-host queue writes that time out, a
community harness's first input and OpenClaw's named conversations. Each keeps
its known limits, and the account and runtime moves say they were tested with
stubs. The what's-new note no longer says the map is out of date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:11:47 +00:00
dev-driver 235a46c143 docs(changelog): add the 0.7.2 entry 2026-10-25 18:00:16 +00:00
dev-driver a48d959ba0 chore(release): prepare 0.7.2: version, installer pins, openrig-core 0.1.19 and what's new
- Version fields 0.7.1 to 0.7.2 in the root, cli, daemon and ui package.json
  and the five matching package-lock.json lines (not class-variance-authority's
  own 0.7.1).
- Installer pins v0.7.0 to v0.7.2 in README, getting-started and quickstart.
  Main still carried 0.7.0's pins, since 0.7.1's pin change was candidate-only.
- openrig-core 0.1.17 to 0.1.19 in both plugin manifests. 0.7.1 published
  0.1.18 from its candidate; since then the bundled delegating-work and
  openrig-user skills changed, and startup's managed refresh only replaces
  installed files when the bundled version is newer.
- docs/reference/whats-new.md (and its context-pack symlink) describes 0.7.2,
  in the same commit as the version, as the package-content test requires.
2026-10-25 18:00:08 +00:00
dev-driver 4191955389 Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"
This reverts commit 6c31f02706 for the 0.7.2 candidate, retaining 0.7.1's exclusion (Root's option B). Derived against 4222e608, not replayed from 0.7.1's ef07562d: since 0.7.1's cut, claude-code-adapter.ts, claude-resume.ts, claim-service.ts and startup.ts changed. Two conflicts resolved: the adapter's managed-blocks import keeps main's later names, and startup's Claude adapter keeps main's restoreReadPolicy while dropping #1345's resolvePermissionMode and its import. Eight files' changed lines equal #1345's reversed; startup's differ only by main's added argument on the same line.
2026-10-25 17:42:50 +00:00
849 changed files with 4574 additions and 57438 deletions

No files matched your search

+1 -36
View File
@@ -3,8 +3,7 @@ name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
# Package suites deny external networking; scenario runtime has no external route.
on:
push:
@@ -77,40 +76,6 @@ jobs:
- run: npm ci
- run: npm run test:repo
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install the real terminal and staging tools
run: |
sudo apt-get update
sudo apt-get install --yes tmux rsync
- run: npm ci
- run: npm run build
- name: Check seat environments without ambient credentials or external network
run: |
set -euo pipefail
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
mkdir -p "$suite_root/home"
# Only loopback exists in this namespace. Drop back to the runner user
# before the CLI, private daemon and private tmux server are started.
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
set -euo pipefail
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
if: ${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
+3 -4
View File
@@ -290,10 +290,9 @@ packages.
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
+4 -1374
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.2` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+15 -2
View File
@@ -6,9 +6,22 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.6
## Planned for 0.7.2
[0.7.6 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6): follow a slice's work in one terminal workspace, plus steadier daemon start and delivery. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
- **Outside assistants over MCP (if ready):** Assistants such as ChatGPT and Claude Desktop find your team, delegate
work, follow it and get the results through a maintained MCP server, locally or over an authenticated network
connection.
- **More harnesses as seats (if ready):** One plug-in point for other coding-agent CLIs, with Hermes, OpenClaw and
Cursor running as normal seats.
- **Accounts (if ready):** Each seat can use its own provider account, and an agent can move one seat to another account
and keep its conversation.
- **Subscriptions (if ready):** One Fleet agent manages two registered Codex subscriptions across two Linux machines, following
your standing preferences.
- **Member permissions (if ready):** Each seat gets its own permission rules, even when seats share a project folder.
- **Stream decisions (if ready):** Opt in, and observations on a rig's stream reach the work they belong to, with
visible results and effects you can recover.
- **Named people in Slack (if ready):** Agents can address several named people in one Slack workspace, and each person
sees and answers their own requests.
## How to help
+1 -2
View File
@@ -49,8 +49,7 @@ COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz
RUN set -eux; \
npm install -g /tmp/openrig.tgz; \
rm -f /tmp/openrig.tgz; \
rig --version; \
rig --help
rig --version
# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the
# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) ---
@@ -281,34 +281,6 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`no_conversation_found` (`:429–434`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:444–450`) — the adapter never relaunches on its own.
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
otherwise ready history probe only for the matching fresh launch, binding and
launch generation. The current pane must show a prompt, and `assessManagedProbe`
must confirm the new launch's exact session token in the same foreground process
across two observations, with the pane and launch still current. It reuses the
existing managed identity proof, including its process fingerprint. A process
label alone, old scrollback, an unavailable screen, an unreadable process list
or an unidentified Claude process cannot admit the handover. Those cases stay
`awaiting_runtime`; a positive identity conflict and visible approval or login
gates keep their specific results. The handover waits within its existing
readiness budget before sending the context packet.
The fresh path requires the last `❯` prompt to sit inside Claude's complete
input frame: its top rule directly above the prompt and a bottom rule below.
A shell's echoed launch command cannot borrow an earlier header or input
frame, even when the matching Claude process is already running. A header
and prompt alone, or a partially drawn frame, remain pending. This may delay
readiness until the full frame is visible; other launch paths keep their
existing screen predicates.
Previously, unavailable observations and ambiguous headerless prompts could
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
missing observations, unknown or changing process identity, retained UI above
direct and script launch commands, partial frames, recovery and normal prompt
controls. `claude-handover-readiness.test.ts` exercises the real handover
service with migrated SQLite, modeled pane/process observations and one synthetic
clock. Readiness and the later packet-submission receipt remain separate checks;
these tests do not run a live Claude account.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
@@ -398,33 +370,6 @@ adapter never closes a session on it and reports `rex_outcome_unknown`.
Herdr and Rex build their layout trees from one grid helper
(`buildTileGrid`, `tile-layout.ts`).
### Tmux server selection
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
`domain/tmux-server-selection.ts`. `terminal.tmux_server` defaults to `inherit`;
`isolated` selects a socket derived from the canonical OpenRig home. The effective
choice is persisted in `tmux-server.json`. A registered live seat, or an
uninspectable old server, defers a requested change until a later restart.
The persisted `pinned` flag and active marker make isolation and migration records
mandatory, while ordinary inherited records remain advisory. Returning to inheritance
commits an unpinned record before clearing the marker, so offline cleanup cannot reuse
a stale inherited socket or keep enforcing a completed isolation choice.
`TmuxAdapter` receives the selection's `executionArgs` for both shell and argv
transports, including its direct probe/cleanup calls. They are empty for ordinary
`inherit` mode, preserving tmux's environment-based selection; `serverArgs` still
names the server for attachments. The opt-in control client uses the same execution
arguments for its connection, probes and fallback commands. The same startup choice feeds node
inventory, `TerminalService.status()` and previews, composed panes, shared TUI attachment and
CLI viewer management. Inherited SSH panes retain the remote-tmux replacement
wrapper and plain recovery command without requiring remote OpenRig. Isolated
SSH panes resolve the remote instance through its own `rig terminal attach`.
For isolation, self-attachment checks the caller's socket before binding;
offline destruction reads the persisted socket before removing state.
See [tmux server selection](../../reference/tmux-servers.md) for configuration
loading, first-upgrade migration, discovery scope and platform behavior.
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
@@ -65,35 +65,6 @@ resolve → project → deliver pre-launch files → persist replay context →
## 2. Execution and projection types
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
persisted independently in `nodes.claude_mcp` (migration 141). The
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
invalid values for the canonical validator to reject. An authored relaunch
updates the seat choice after launch eligibility and startup-source resolution;
an already-bound seat or a failed tmux launch keeps its prior value. Export
describes the current runtime; a dormant choice on another runtime is omitted
and must be restored in the Claude declaration when rebuilding from that export.
The YAML export route names those agents in the existing `# Not carried:` report;
the JSON export is unchanged. `rigspec-routes.test.ts` covers both pod-aware and
legacy exports, omission controls and successful JSON export.
The shared `StartupOrchestrator` reads the current seat record, so both an authored start
and saved-context restore pass the choice to Claude. Legacy resume and handover
successors read it separately; a runtime-switch successor reads it only when
the target is Claude. `claude-mcp.ts` builds the strict empty-server arguments
for the adapters, shutdown resume probes and displayed recovery commands,
including the conversation picker. Shutdown skips a probe with an unreadable
saved choice and keeps the resume token without adding a shutdown refusal.
The native lineage selectors recognize these exact
arguments without treating JSON contents as session options. Invalid stored
values refuse launch. With no choice, the adapters add no MCP arguments.
`claude-mcp-lifecycle.test.ts` exercises real schema/storage, export, snapshot
and restore with simulated tmux/provider processes. `claude-mcp-launch.test.ts`
executes managed and unmanaged shell commands against a private argv-printing
fake, including fresh/resume/fork and legacy resume. These tests verify command
plumbing and identity handling; they do not claim live provider enforcement.
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
spec/projection types live here.
@@ -148,37 +119,6 @@ them imports Hono: **0**
- `startup-resolver.ts` — additive startup layering.
- `projection-planner.ts` — runtime resource projection planning.
### Profile resolution during launch
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
the pod-aware apply path, materialized-member launch and first-start retry called
the synchronous resolver again on the daemon's request thread. A fallback skill
scan or managed-catalog inspection could therefore delay every other request even
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
Those three launch paths now await the same resolver in a worker before projecting
resources or starting the member. Configuration precedence and launch order still
come from the existing resolver and instantiator.
An apply operation reuses one lazy worker across its members and terminates it on
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
reads current files, including changes made by an earlier member's startup.
Standalone member launches and first-start retries use a short-lived worker for
their resolution. This adds worker startup and context-copy costs; it does not
reuse the preflight result or cache configuration between requests. The helper's
existing synchronous fallback for a deleted process working directory remains.
Worker errors become configuration-failure results. Apply can then follow the
existing all-members-failed cleanup, including service rollback and restoration
of replaced generations. Materialized launches report a failed member, and
first-start retries refuse before writing retained state.
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
requires an HTTP health response while that FIFO is still blocked, then checks
that the selected skill reaches projection. It covers all three launch paths and
a skill edit between members. These fixtures use SQLite and the real instantiator
with a simulated runtime; they do not establish native provider behavior or
responsiveness during other synchronous parts of startup.
## 4. Startup orchestration (the spec-startup contract)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
@@ -178,58 +178,15 @@ the destination's orchestrator when one is derivable, otherwise to the
destination itself, and that seat may be dead. Overdue claims go to their
destination. Repeated detections reuse the existing finding without changing
its route; when the source condition resolves, the sweep closes its own finding.
Previously, falling out of a reporting query could itself close a delivery or
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
an unreadable human registry or a larger reporting-age setting could therefore
look like successful recovery. These findings now require source evidence:
closure, pickup or parking of the work, a verified/accepted wake, or a posted
notification for the current human recipient and episode, as appropriate to the
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
legacy human receipts do not prove resolution. Uncertainty keeps an existing
finding open without itself sending another wake or creating another finding.
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
does not prove pickup.
A legacy human notice with no owner-notification history can still resolve from
the existing item-scoped posted ledger when its recipient is confirmed human.
This covers older pending asks and parks without inventing an episode that was
never recorded. A registered alias needs a readable registry to establish its
human recipient; reserved human references remain known during an outage.
Legacy receipts carry no recipient key, so a destination reroute requires a new
matching notification episode. A legacy park is accepted only when no earlier
notice or reroute predates it, and later history contains only receipts or
asking-agent reroutes within that park. A re-park, an exit from the park or an
ambiguous append cannot establish the same recipient without owner history;
the finding waits for a new matching episode or other source resolution.
Rerouting only the asking agent within an otherwise unchanged park preserves
its human recipient (`blockedOn`).
Modern notifications still require their current episode, and an unrelated
legacy human receipt cannot resolve an agent wake.
A known hold still leaves recovery with the existing mechanism: an ask on
Steering without an alert, a wake held by the seat typing guard, or a live
quiet-release job. Its closure note names that disposition rather than claiming
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
returning a row to `pending` while retaining its claim no longer hides a later
stall or closes its existing stalled-claim finding. A retained failed wake cannot
override that pickup evidence. The agent's claim still does not establish delivery
of a separate notification to a person.
That automatic closure records `stuck-sweep@system` as the actor in both the
transition and the `queue.updated` audit event, with the source evidence in the
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
with the detector's action. The finding's source,
transition and the `queue.updated` audit event. Previously it credited the
finding's source agent with the detector's action. The finding's source,
destination and claim remain its existing ownership records; the underlying work
is not reassigned, and existing transition history is not rewritten.
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
claims, current human-notification episodes and registry outages with migrated
SQLite, including positive recovery controls and no repeated send on an
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
pending and claimed findings close once, name the sweep in their transition and
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed
findings close once, their transition and event name the sweep, and ownership
stays intact. The posted-human-notice case also checks the actor while leaving
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
@@ -73,26 +73,10 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
first three; `not_attempted` is set when pre-restore validation fails
(`:281`).
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
**8** optional fields, kept optional so older snapshots still load
**7** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
Periodic snapshots retain a down agent's whole authoritative resume-source
session in `periodicResumeSourcesByNode`, without marking it running. A named
single-agent launch using automatic periodic selection can resume that exact
carried source; an explicit snapshot or whole-rig restore still asks about an
absent agent with resumable history. Tokenless and non-resume-policy agents
keep their existing fresh path. Only the node's newest session with a token
and `resume_if_possible` policy is carried; a current fresh-successor relation
must agree. Named launch rechecks the live newest session, resume policy and
fresh-successor relation under its lifecycle lease, before changing any seat
state; a source retired after capture stops and asks without launching.
Idle-shell carry advice uses automatic selection again, not an
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
restore; explicit reviewed-history selection keeps its existing checks.
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
if the earlier-source lookup fails: capture still records current crash
insurance and logs that the resume sources were unavailable.
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
@@ -293,7 +277,7 @@ cited here by number with the code that carries them):
for one seat. `resume` captures an eligibility-checked `auto-rehydrate`
snapshot when none exists and goes through `launchSingleNode`.
- **Seat handover** — `rig seat handover <seat> --source
fresh|rebuild|fork:<seat>|discovered:<id> --reason <reason> [--dry-run]`
fresh|rebuild|fork:<id>|discovered:<id> --reason <reason> [--dry-run]`
(the reason is required, dry run included; without it the command exits 2
before contacting the daemon)
(`packages/cli/src/commands/seat.ts:364`) calls
@@ -405,20 +389,6 @@ daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
@@ -462,40 +462,6 @@ agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
### Current-generation records for `rig walk`
`rig walk` checks a delivered piece against the current conversation through
`GET /api/sessions/:sessionName/generation-record` (`routes/sessions.ts`).
Without `sinceBytes`, the route returns record identity and size; with it, the
route also reads a bounded suffix from the same open file. This terminal-class
endpoint uses the terminal bearer gate.
For Codex, the current occupant must have a verified identity for its bound
pane. The route compares tmux's current root PID with the identity's
`observedPanePid`, falling back to `observedPid` for older verdicts that only
recorded one PID. Previously it always compared the root with the native Codex
PID, refusing a verified Codex running beneath a shell or launcher. Recording
both PIDs lets that wrapper pass without accepting a changed pane root or
changing the identity schema.
For dual-PID verdicts, the selected thread must also be the thread resolved for
`observedPid`. All candidate threads still participate in the ambiguity check;
another process's resolvable thread cannot stand in for a missing or
unresolvable verified native process. This reuses the same process observation
and thread lookups, without an additional census.
The existing thread lookup still requires a Codex descendant with one
resolvable thread, a thread-table record path and a matching rollout header.
The route rechecks occupant generation, pane binding and root PID after the
thread lookup. These are bounded observations, not an atomic process snapshot.
An unresolved identity still returns 409; `rig walk` reports consumption as
unverified instead of claiming it read the record. Route regressions in
`packages/daemon/test/walk-consumption-primitives.test.ts` cover shell and
launcher wrappers, legacy verdicts, root changes, replaced or unresolved native
processes, and changes during thread resolution with synthetic process
observations and actual SQLite/JSONL
fixtures; they do not exercise a live Codex conversation.
## 5. Chat flow
1. `rig chatroom send <rig> "message"` → `POST
+14 -50
View File
@@ -9,23 +9,23 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
last-updated: 2026-10-26
last-verified-against-source: 71b9c2c072ac5d27a23fb9fb404faa7033cb7e42
last-updated: 2026-10-24
---
# CLI Reference — Registered Commands and Options
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
Verified against source commit `71b9c2c072ac5d27a23fb9fb404faa7033cb7e42`.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
There are **90 top-level registrations**, **410 registered command objects**
There are **90 top-level registrations**, **401 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
**1,274 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
These are counts for that source snapshot, not a claim about a deployed release.
## Reading this reference
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
@@ -208,23 +208,7 @@ the sections that were not read are `null`.
| `rig gateway human show <entityId>` | — | `--json` |
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human remove <entityId>` | — | `--force` |
| `rig gateway channel` | — | — |
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
| `rig gateway channel remove <id>` | — | `--force` |
| `rig gateway org` | — | — |
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway group` | — | — |
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
| `rig gateway default [entityId]` | — | `--clear` |
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
### parked
@@ -354,23 +338,6 @@ the sections that were not read are `null`.
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
`laptop`, run this on the local instance to include its attention items:
```sh
rig config set feed.subscriptions.laptop.enabled true
```
The key uses the registry id, including any dots, and defaults to `false`. Registration
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
than an invalid command. `--json` keeps its single JSON result without these hints.
The host CLI tests execute the advertised command against an isolated config and cover
registration, reserved names and JSON output; pairing responses are simulated, so they
do not establish connectivity to a live peer.
### ps
| Invocation | Aliases | Declared options |
@@ -393,7 +360,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>` |
| `rig mcp token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
@@ -485,7 +452,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig slack` | — | — |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reactions <mode>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack status` | — | `--json` |
| `rig slack manifest` | — | `--url`<br>`--json` |
| `rig slack verify` | — | `--json` |
@@ -495,7 +462,6 @@ do not establish connectivity to a live peer.
| `rig slack channel-map remove <match>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack enable` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack disable` | — | `--reason <reason>` **required**<br>`--actor <actor>` |
| `rig slack post-open-asks` | — | `--reason <reason>`<br>`--wait <seconds>`<br>`--actor <actor>`<br>`--json` |
| `rig slack outbound` | — | `--json` |
| `rig slack inbound` | — | — |
@@ -586,7 +552,7 @@ Herdr session per request, including session-bound previews and window reuse.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--readable`<br>`--size <WxH>`<br>`--host <id>`<br>`--json` |
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--host <id>`<br>`--json` |
### broadcast
@@ -794,8 +760,8 @@ Only Claude and Codex tenures can be woken.
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--reason <text>`<br>`--model <id>`<br>`--codex-config-profile <name>`<br>`--dry-run`<br>`--json` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
@@ -973,7 +939,6 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig seat` | — | — |
| `rig seat history` | — | `--rig <id>` **required**<br>`--node-id <id>` **required**<br>`--snapshot-id <id>`<br>`--history-session-id <id>`<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
@@ -982,12 +947,11 @@ for input and receipt examples, rollback, and snapshot behavior.
| `rig seat switch-client <seat>` | — | `--to-window <n>`<br>`--client <id>`<br>`--json` |
| `rig seat clear-attention <session>` | — | `--reason <text>`<br>`--json` |
| `rig seat set-terminal-policy <seat>` | — | `--preset <preset>` **required**<br>`--helper <addresses...>`<br>`--rig-default`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json`<br>`--peer-compaction <decision>`<br>`--peer-model <decision>`<br>`--self-compaction <decision>`<br>`--self-model <decision>`<br>`--openrig-compaction <decision>`<br>`--openrig-model <decision>` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-permissions-to <seat>` | — | `--to <person>`<br>`--clear`<br>`--deny`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat effort <seat>` | — | `--json` |
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-cwd <seat>` | — | `--cwd <directory>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>`<br>`--plan`<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat restart <seat>` | — | `--keep-conversation` **required**<br>`--reason <text>`<br>`--plan`<br>`--verify`<br>`--expect-account <text>`<br>`--operator <address>`<br>`--host <id>`<br>`--json` |
+14 -56
View File
@@ -88,11 +88,6 @@ are not yet runnable checks.
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
@@ -109,17 +104,7 @@ credentials are supplied.
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
@@ -158,7 +143,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
@@ -168,28 +153,6 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
### Host mode: while you're writing a scenario
The tmux server selection regression also uses these scaffold and lifecycle helpers
in `packages/daemon/test/tmux-server-isolation-daemon.test.ts`. It runs a real stub
seat through config changes, daemon restarts, capture, self-attachment and cleanup,
while native socket observations verify that a personal namesake survives. The
test also removes the effective-server record and verifies that `destroy --all`
leaves the daemon, database and both servers' sessions intact before restoring
the record and checking normal cleanup. The
YAML grammar has no config-write or socket-inventory surface, so these assertions
live in the Vitest integration test. It does not exercise a real provider model,
SSH connection or graphical terminal window.
`packages/daemon/test/tmux-server-two-instances.test.ts` adds two real daemons
sharing a scratch OS home and inherited personal tmux server, with separate
OpenRig homes, databases and ports. Both enable isolation and control mode. It
checks same-named seats through inventory, capture and verified writes, observes
the marked control session on each selected socket, then stops one instance and
checks that the other instance's pane identity, control session and delivery still
work. The personal namesake remains untouched. This test requires a tmux version
that supports control mode and the built CLI and daemon. Like CI's package suites,
it should run with scratch homes and outbound network restricted to localhost;
scripted seats prove terminal routing and rendered delivery, not model receipt.
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
@@ -212,12 +175,10 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy (six containers); the
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
fifteen fresh containers.
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
@@ -227,13 +188,10 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
@@ -300,7 +258,7 @@ means:
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
@@ -341,9 +299,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
+4 -16
View File
@@ -55,17 +55,8 @@ transitions before trying again. Do not silently send the task a second time.
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
returns an echo artifact. Fetch that revision:
```sh
git clone https://github.com/a2aproject/a2a-samples.git
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
```
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
The sample does not implement the input-required and cancellation controls described later. Use a peer that
supports those operations when testing them.
returns an echo artifact. Its README explains startup; it does not implement the input-required and cancellation
controls described later. Use a peer that supports those operations when testing them.
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
@@ -137,8 +128,7 @@ async def main():
asyncio.run(main())
```
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
Run it from your seat, replacing the row and peer URL:
```sh
export A2A_URL=http://127.0.0.1:9999
@@ -270,7 +260,7 @@ export A2A_SEAT=<worker-session-from-rig-ps>
# Client terminal, in the folder with the example scripts and venv:
unset A2A_ROW A2A_TASK A2A_CONTEXT
export A2A_URL=http://127.0.0.1:19092
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
export A2A_TEXT='Return the words hello from the receiving seat.'
.venv/bin/python a2a_client.py
```
@@ -287,8 +277,6 @@ rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
the worker must be watching its queue; merely starting the front does not start or wake an agent.
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
+2 -47
View File
@@ -244,15 +244,6 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
@@ -364,37 +355,10 @@ declaration isn't changed either; update it separately if a future recreation sh
Each startup file and action specifies when it applies:
- `fresh_start` — delivered when a new conversation starts: the first launch, `rig seat launch --fresh`,
`rig seat continue`, and seats restored with `--fresh`
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- `restore` — delivered when a seat is restored, including a restore whose resume falls back to a fresh conversation
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
---
## What a seat is set to, and what it's running
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
running seat only through the action in the last column.
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|---|---|---|---|
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
---
@@ -434,11 +398,6 @@ Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof"
`idempotent` is required, and an action that isn't idempotent must not list `restore` in `applies_on`. Both phases
run after the harness is ready: `after_files` (the default) after the post-launch files, then `after_ready`.
**Don't rename a managed Claude seat.** OpenRig starts every Claude seat with `--name <session>`, on a fresh start and on
every resume, and its own reads of the seat use that name: context usage and managed compaction read the status-line
record Claude saves under it, and the input box is recognized by the name on its border. A startup `/rename` to another
name breaks those reads until the next resume puts the session name back.
### Current Support Matrix
| Capability | Status | Notes |
@@ -562,10 +521,6 @@ remain shared.
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
For Pi tool-selection diagnosis and deliberate in-place recovery, see
[Pi tool-selection recovery](pi-tool-selection-recovery.md). It distinguishes
configured, currently intended, registered and active tools, including the TUI/RPC boundary.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
+1 -28
View File
@@ -41,13 +41,6 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
Both herdr and omnigent converged on this exclusion, with warning comments on the
collision in both codebases.
The legacy `agentActivity` field distinguishes a typed provider interruption from
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
hook. A permission prompt can independently supply human-input evidence. See
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
for the separate provider signal and queue timer.
### Per-seat attention in the node inventory
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
@@ -102,7 +95,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
@@ -139,26 +132,6 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
-74
View File
@@ -1,74 +0,0 @@
# Integration author contracts — DRAFT, published with 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
## Release and support status
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. References below use the immutable base instead of a moving branch.
| Area | Status at this baseline | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
## How the pieces compose
```mermaid
flowchart TD
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
R --> H["Built-in runtime or community preset"]
H --> N["Harness / runner: native conversation and control"]
H --> P["Author platform integration: placement and resources"]
P --> N
P --> F["Workspace, retained state and returned artifacts"]
R --> S["Optional application services"]
```
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
| Owner | Responsibility |
|---|---|
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
## Lifetime and recovery
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
## Questions contributors can help resolve
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
- What exact state and ownership must travel when compute is recreated or moved?
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
-42
View File
@@ -67,45 +67,3 @@ directory: its timestamp should advance and its `session_id` should match the
current Claude session. A visible custom status line alone does not verify
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
collector errors; remove its stderr redirection temporarily when diagnosing it.
## Claude usage-limit detection
The status line supplies subscription usage windows and their reset timestamps.
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
provider interruption even when no status line window is available. This path
requires the updated OpenRig hook configuration and the current seat's runtime
and occupant generation. Unknown identity or an expired event cannot trigger a
usage-limit pause; a hook with a newer source timestamp supersedes the event,
regardless of arrival order. The event expires after five minutes in any case.
A helper Claude started from a seat's shell inherits that seat's environment,
so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
existing `explicit_unknown` host diagnosis.
Pending work handled by the wake ladder uses the existing local Claude pool
(the one-account-per-host assumption), with separate pools for seats that
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
source, provider usage remains unknown.
@@ -11,4 +11,3 @@ launch_result: true
resume: true
startup_assisted: false
first_input: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -34,12 +34,6 @@ chat_store() {
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
set -- --model "$OPENRIG_MODEL" "$@"
fi
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
@@ -1,82 +0,0 @@
# An example runner for DeepSeek Harness (dsh)
`openrig-dsh-runner.mjs` is a small runner for the draft [harness and runner contract](../../harness-runner-contract.md).
It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and does the runner's part of each contract
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
It has no dependencies, and it isn't installed by OpenRig. Copy it with [`dsh.yaml`](dsh.yaml) and maintain both as your
own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh runner by its own maintainer.
## Set it up
1. **Install dsh and Node.** Install Node 22 or later, then `npm i -g @deepseek-ai/[email protected]`. This is the version
it was written for, and it has been checked only against a stand-in agent.
2. **Install the runner and preset.** Make the runner executable and put it on the seat's `PATH`. Copy `dsh.yaml` to
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
`env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary.
The runner's own defaults for dsh:
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
## Permissions
The runner submits each request to OpenRig, which asks one person:
- the default person;
- or the person chosen for the seat with `rig seat set-permissions-to <seat> --to <person> --reason "…"`.
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
**`allow_always` creates a standing grant in dsh.** OpenRig doesn't record or revoke it, which is why the runner prefers
`allow_once`. Where dsh keeps the grant, and for how long, isn't checked.
**The runner refuses** only when the seat's operator opted in to refusal, with `rig seat set-permissions-to <seat>
--deny`, or when dsh offered no allow option. A refused request is shown in the pane, and the seat reads needs input
until the next prompt.
**The runner answers cancelled, holding nothing,** when the person says no and dsh offered no reject option, or when
OpenRig says the request isn't the seat's current occupant's (for example, after a relaunch).
## Before you declare resume
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
- **Only dsh's own not-found error (`-32002`) counts as missing.** Any other failure leaves the resume unconfirmed.
- **When dsh advertises `session/list`** (it does), the runner first asks the list, following its pages. A conversation
the list names but that won't reopen stays unconfirmed, even on a not-found.
- **An unlisted conversation is still reopened.** dsh's list can leave out a conversation another running dsh holds
unsaved, so it can't show a conversation is gone.
**Limit:** dsh answers `-32602`, not `-32002`, for a conversation it doesn't have, so the runner can't report a missing
dsh conversation as missing. It reads unconfirmed.
Add `resume: true` after a real dsh has reopened a conversation and recalled what was said in it.
## Check it without a model
Point dsh's `DEEPSEEK_BASE_URL` at a local server that speaks DeepSeek's Anthropic-compatible Messages API. Then run the
contract's real check:
- a seat reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled.
`packages/daemon/test/community-harness-dsh-example.test.ts` runs the same cases against a stand-in agent.
@@ -1,20 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/dsh.yaml, with openrig-dsh-runner.mjs (executable) on the seat's PATH.
format: 1
name: dsh
command: openrig-dsh-runner.mjs
maintainer: Your name or team
runner: true
launch_result: true
first_input: true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
- no guidance, skills or startup files are placed; dsh reads its own AGENTS.md and skill folders
- when the pane closes, the runner cancels a running turn and stops dsh; anything dsh's tools started may keep running
- fork unsupported
@@ -1,565 +0,0 @@
#!/usr/bin/env node
// Example OpenRig runner for DeepSeek Harness (dsh), not installed by OpenRig. Checked against a stand-in agent only;
// written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh), ACP protocol 1 and Node 22 or later. No dependencies.
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
// only when the seat opted in to refusal). Copy it and maintain it as your own.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
const READ_TRIES = 3; // reads in a row that get no answer before the runner permits
// Refusals that say the request certainly isn't the seat's current occupant's live one: cancel it, and hold nothing.
const NOT_LIVE = ["stale_generation", "activity_grant_wrong_seat", "activity_grant_stale"];
// A read's states that end the request, answered cancelled (an `answered` that reaches there names an option dsh didn't
// offer). Any other state, like an unlisted status, permits.
const ENDED = ["refused", "withdrawn", "stale", "answered"];
const CANCELLED = { outcome: { outcome: "cancelled" } };
const ACP_RESOURCE_NOT_FOUND = -32002;
// The most pages of dsh's session list read before a resume; a longer list decides nothing.
const LIST_PAGES = 50;
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
/** Turns terminal bytes into submitted blocks. Everything up to an Enter outside a paste is one block, line breaks
* included; a block that is exactly /abort, or a Ctrl-C byte, is the only control. */
export class InputFramer {
#buffer = "";
#pasting = false;
#partial = "";
push(chunk) {
const events = [];
const data = this.#partial + chunk;
this.#partial = "";
let i = 0;
while (i < data.length) {
if (data[i] === "\x1b") {
const rest = data.slice(i);
if (rest.startsWith(PASTE_START) || rest.startsWith(PASTE_END)) {
this.#pasting = rest.startsWith(PASTE_START);
i += PASTE_START.length;
continue;
}
if (PASTE_START.startsWith(rest) || PASTE_END.startsWith(rest)) { this.#partial = rest; break; }
if (!this.#pasting) { i += escapeLength(rest); continue; } // arrow keys and the like
}
const ch = data[i];
i += 1;
if (ch === "\x03") { this.#buffer = ""; this.#pasting = false; events.push({ type: "abort" }); continue; }
if (this.#pasting) {
if (ch === "\r") { if (data[i] === "\n") i += 1; this.#buffer += "\n"; } else this.#buffer += ch;
continue;
}
if (ch === "\r" || ch === "\n") {
const text = this.#buffer;
this.#buffer = "";
if (text.trim() === "/abort") events.push({ type: "abort" });
else if (text.trim()) events.push({ type: "block", text });
continue;
}
if (ch === "\x7f" || ch === "\b") {
if (this.#buffer) { this.#buffer = this.#buffer.slice(0, -1); events.push({ type: "erase" }); }
continue;
}
if (ch >= " ") { this.#buffer += ch; events.push({ type: "echo", text: ch }); }
}
return events;
}
}
function escapeLength(rest) {
if (rest[1] === "[") {
let j = 2;
while (j < rest.length && !(rest.charCodeAt(j) >= 0x40 && rest.charCodeAt(j) <= 0x7e)) j += 1;
return Math.min(j + 1, rest.length);
}
return rest[1] === "O" ? Math.min(3, rest.length) : 1;
}
/** JSON-RPC 2.0 with the agent, one message per line on its stdio. */
class AgentPeer {
#child;
#next = 1;
#waiting = new Map();
#requests = new Map();
#notifications = new Map();
constructor(child, onNoise) {
this.#child = child;
createInterface({ input: child.stdout }).on("line", (line) => {
let message;
try { message = JSON.parse(line); } catch { message = null; }
if (!message || typeof message !== "object") { if (line.trim()) onNoise(line); return; }
if (message.method === undefined) {
const waiter = this.#waiting.get(message.id);
if (!waiter) return;
this.#waiting.delete(message.id);
if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? "error"), { code: message.error.code }));
else waiter.resolve(message.result);
return;
}
if (message.id === undefined) { this.#notifications.get(message.method)?.(message.params ?? {}); return; }
const handler = this.#requests.get(message.method);
if (!handler) { this.#write({ id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } }); return; }
Promise.resolve().then(() => handler(message.params ?? {}, message.id)).then(
(result) => this.#write({ id: message.id, result }),
(error) => this.#write({ id: message.id, error: { code: error.code ?? -32603, message: error.message } }));
});
}
request(method, params) {
const id = this.#next++;
return new Promise((resolve, reject) => { this.#waiting.set(id, { resolve, reject }); this.#write({ id, method, params }); });
}
notify(method, params) { this.#write({ method, params }); }
onRequest(method, handler) { this.#requests.set(method, handler); }
onNotification(method, handler) { this.#notifications.set(method, handler); }
failAll(reason) {
for (const waiter of this.#waiting.values()) waiter.reject(new Error(reason));
this.#waiting.clear();
}
#write(message) {
if (this.#child.stdin.writable) this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
}
}
const openrigHome = () => process.env.OPENRIG_HOME || join(homedir(), ".openrig");
/** The daemon's base URL and the seat's token: from the environment, else from activity-endpoint.json. */
function openrigEndpoint() {
const env = process.env;
let base = env.OPENRIG_URL || (env.OPENRIG_PORT ? `http://${env.OPENRIG_HOST || "127.0.0.1"}:${env.OPENRIG_PORT}` : "");
let token = env.OPENRIG_ACTIVITY_HOOK_TOKEN || "";
if (!base || !token) {
try {
const file = JSON.parse(readFileSync(join(openrigHome(), "activity-endpoint.json"), "utf8"));
base ||= file.baseUrl ?? "";
token ||= file.token ?? "";
} catch { /* no daemon has written one yet */ }
}
return base && token ? { base: base.replace(/\/+$/, ""), token } : null;
}
async function callOpenRig(method, path, body, timeoutMs) {
const endpoint = openrigEndpoint();
if (!endpoint) return { status: 0, body: null };
try {
const response = await fetch(`${endpoint.base}${path}`, {
method, signal: AbortSignal.timeout(timeoutMs),
headers: { authorization: `Bearer ${endpoint.token}`, ...(body ? { "content-type": "application/json" } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
let parsed = null;
try { parsed = await response.json(); } catch { /* not JSON */ }
return { status: response.status, body: parsed };
} catch {
return { status: 0, body: null };
}
}
/** Activity posts go one at a time, in order, and never hold up the agent. */
class Activity {
#sequence = 0;
#chain = Promise.resolve();
constructor(identity) { this.identity = identity; }
post(hookEvent, subtype, extra = {}) {
const body = { ...this.identity, sequence: ++this.#sequence, occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra };
this.#chain = this.#chain.then(() => callOpenRig("POST", "/api/activity/hooks", body, POST_TIMEOUT_MS)).then(() => {}, () => {});
}
flushed() { return this.#chain; }
}
const UPLOADS_OFF = [
"# Written by openrig-dsh-runner for this seat; safe to edit, and an existing file is left alone.",
"# Don't attach this seat's session log, or its plugin package names and versions, to DeepSeek API requests.",
"- id: session-log-deepseek",
" config:",
" enabled: false",
"- id: plugin-package-inventory-deepseek",
" config:",
" enabled: false",
"",
].join("\n");
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names
* one you prepared yourself, which is used as it is. */
function seatHome() {
if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME;
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
const home = join(openrigHome(), "state", "dsh", seat, "home");
mkdirSync(home, { recursive: true, mode: 0o700 });
const patch = join(home, "cordis.patch.yml");
if (!existsSync(patch)) writeFileSync(patch, UPLOADS_OFF, { mode: 0o600 });
return home;
}
function writeLaunchRecord(fields) {
const path = process.env.OPENRIG_LAUNCH_RESULT;
if (!path) return;
const record = { format: 1, attempt: process.env.OPENRIG_LAUNCH_ATTEMPT, harness: NAME, ...fields };
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
renameSync(temp, path);
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const say = (line) => process.stdout.write(`${line}\n`);
const firstLine = (text) => {
const lines = text.split("\n");
return lines.length > 1 ? `${lines[0]} … (+${lines.length - 1} lines)` : lines[0];
};
async function main() {
const env = process.env;
const cwd = process.cwd();
let firstInput = null;
if (env.OPENRIG_FIRST_INPUT) {
try { firstInput = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8"); } catch (error) {
console.error(`openrig-dsh-runner: the first startup turn couldn't be read (${error.message}); not starting`);
process.exit(2);
}
}
const home = seatHome();
const activity = new Activity({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID,
runtime: NAME, generation: env.OPENRIG_OCCUPANT_GENERATION });
const agentEnv = { ...env, DSH_HOME: home };
delete agentEnv.OPENRIG_ACTIVITY_HOOK_TOKEN; // dsh's shell tool inherits this environment
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let tty = false;
const restoreTty = () => {
if (!tty) return;
tty = false;
process.stdout.write("\x1b[?2004l");
try { process.stdin.setRawMode(false); } catch { /* already closed */ }
};
const finish = async (code) => {
clearInterval(heartbeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
};
const fatal = async (message, code) => {
stopping = true;
say(`[openrig-dsh] ${message}`);
if (!agentExited) {
child.kill("SIGTERM");
await Promise.race([new Promise((done) => child.once("exit", done)), sleep(3_000)]);
}
await finish(code);
};
const postIdle = (subtype) => activity.post("Stop", subtype, { livenessSeconds: LIVENESS_SECONDS });
const withdraw = (askId, reason) => track(callOpenRig("POST", `/api/activity/permission-requests/${encodeURIComponent(askId)}/withdraw`, { reason }, POST_TIMEOUT_MS));
child.once("error", (error) => { agentExited = true; void fatal(`dsh couldn't be started (${error.message}); is it on PATH?`, 1); });
child.on("exit", (code, signal) => {
agentExited = true;
peer.failAll("dsh exited");
if (stopping) return;
for (const ask of state.asks) ask.end("exited");
say(`\n[openrig-dsh] dsh exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
activity.post("Stop", `${NAME}_exited`);
void finish(code === 0 ? 0 : 1);
});
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
} else if (update.sessionUpdate === "tool_call") {
say(`\n[tool] ${update.title ?? "tool"} (${update.kind ?? "other"})`);
if (reporting) activity.post("PreToolUse", String(update.kind ?? "other"));
}
});
const refuse = (why) => {
state.holding = true;
state.refused = true; // the seat's own refusal holds needs input until the next prompt
say(`[permission] refused: ${why}. The seat reads needs input until the next prompt.`);
activity.post("Notification", "permission_prompt", { turnId: String(state.turn) });
return CANCELLED;
};
// The answered path: OpenRig opens one ask to the default person (or the seat's override, `rig seat
// set-permissions-to`), and the runner reads the answer back for this same live request. With no one to ask, OpenRig
// permits at once, and so does the runner when OpenRig can't answer. Only the seat's opt-in (`--deny`) refuses; a
// request OpenRig says isn't the current occupant's, or that a person said no to with no reject option, is cancelled.
const askOwner = async (requestId, toolCall, options) => {
const turn = state.turn;
const live = () => state.running && state.turn === turn && !state.cancelRequestedAt && !agentExited;
const submitted = await callOpenRig("POST", "/api/activity/permission-requests", {
sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, generation: env.OPENRIG_OCCUPANT_GENERATION,
nativeRef: state.sessionId, turnId: String(turn), requestId: String(requestId), toolCall,
options: options.map((option) => ({ optionId: option.optionId, name: option.name ?? option.optionId, kind: option.kind })),
occurredAt: new Date().toISOString(),
}, 10_000);
const askId = submitted.body?.ok ? submitted.body.askId : undefined;
if (!live()) {
if (askId) withdraw(askId, state.cancelRequestedAt ? "turn_cancelled" : agentExited ? "exited" : "turn_ended");
return CANCELLED;
}
const offered = (optionId) => options.some((option) => option.optionId === optionId);
// No one can be asked: OpenRig's default is permit, with the agent's own allow option.
if (submitted.body?.ok && submitted.body.state === "permitted" && offered(submitted.body.optionId)) {
say(`[permission] permitted: ${submitted.body.reason ?? "no one can be asked"} (${submitted.body.optionId})`);
return { outcome: { outcome: "selected", optionId: submitted.body.optionId } };
}
// When OpenRig can't answer, nobody can be asked either, so the same default applies. allow_once is preferred, so
// dsh doesn't remember a permission nobody chose.
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = false;
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
say(`[permission] cancelled: OpenRig says this isn't the seat's current request (${submitted.body.code})`);
return CANCELLED;
}
return permit(submitted.status === 404 ? "this OpenRig has no answered path"
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
const ask = { end: (reason) => {
if (ended) return;
ended = true;
state.asks.delete(ask);
withdraw(askId, reason);
if (done) return;
done = true;
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
resolve(CANCELLED);
} };
state.asks.add(ask);
void (async () => {
let unanswered = 0;
while (!done) {
const read = await callOpenRig("GET", `/api/activity/permission-requests/${encodeURIComponent(askId)}?waitSeconds=${WAIT_SECONDS}`, null, (WAIT_SECONDS + 10) * 1_000);
if (done) return;
if (read.status === 0 && ++unanswered < READ_TRIES) { await sleep(2_000); continue; }
if (read.status === 200 && read.body?.state === "pending") { unanswered = 0; continue; }
done = true;
const answer = read.body ?? {};
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
state.holding = false;
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
} else {
state.asks.delete(ask);
state.holding = false; // a person said no, or the request is over: no one is still waiting
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
}
}
})();
});
};
peer.onRequest("session/request_permission", (params, requestId) => {
if (params.sessionId !== state.sessionId) throw Object.assign(new Error(`no turn of conversation ${params.sessionId} runs here`), { code: -32602 });
const toolCall = { title: String(params.toolCall?.title ?? "a tool call"), kind: String(params.toolCall?.kind ?? "other") };
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
return askOwner(requestId, toolCall, options);
});
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
state.cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId: state.sessionId });
say(`\n[openrig-dsh] cancel requested at ${state.cancelRequestedAt}; the turn ends when dsh says so`);
if (!state.refused) state.holding = false; // the seat's own refusal holds until the next accepted prompt
for (const ask of state.asks) ask.end("turn_cancelled");
};
const runTurn = async (text) => {
state.running = true;
state.turn += 1;
state.holding = false;
state.refused = false;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
say(`\nyou ▸ ${firstLine(text)}`);
activity.post("UserPromptSubmit", "accepted", { turnId });
let stopReason;
try {
const result = await peer.request("session/prompt", { sessionId: state.sessionId, prompt: [{ type: "text", text }] });
stopReason = String(result?.stopReason ?? "unreported");
} catch (error) {
if (agentExited) return;
say(`\n[openrig-dsh] the turn failed: ${error.message}`);
activity.post("Notification", "runtime_error", { turnId });
stopReason = "error";
}
state.running = false;
for (const ask of state.asks) ask.end("turn_ended");
say(`\n── turn end (${stopReason}) ──`);
if (state.holding) say("[openrig-dsh] a permission request was refused, so the seat reads needs input until the next prompt");
else postIdle(stopReason);
pump();
};
const pump = () => {
if (state.running || agentExited || state.queue.length === 0) return;
void runTurn(state.queue.shift());
};
const submit = (text) => {
if (agentExited) return;
state.queue.push(text);
if (state.running) say(`[queued: ${state.queue.length}] this goes to dsh after the current turn`);
pump();
};
// Handshake, then open or reopen the conversation, then report it. Nothing is read as ready before this.
let init;
try {
init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig-dsh-runner", version: "1" } });
} catch (error) {
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
}
const capabilities = init?.agentCapabilities ?? {};
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
// cursor is an opaque string, possibly empty, passed back unchanged; an absent or null one ends the list.
const listsConversation = async (id) => {
let cursor;
for (let page = 0; page < LIST_PAGES; page += 1) {
let listing;
try { listing = await peer.request("session/list", cursor === undefined ? {} : { cursor }); } catch { return undefined; }
if (!Array.isArray(listing?.sessions) || listing.sessions.some((session) => typeof session?.sessionId !== "string")) return undefined;
if (listing.sessions.some((session) => session.sessionId === id)) return true;
if (listing.nextCursor === undefined || listing.nextCursor === null) return false;
if (typeof listing.nextCursor !== "string") return undefined;
cursor = listing.nextCursor;
}
return undefined;
};
if (resumeRef) {
// The method dsh advertises; with neither advertised, session/resume, the method dsh 0.2.1-rc.2 answered in its
// maintainer's checks. dsh 0.2.1-rc.2 answers -32602 both for a conversation it doesn't have and for one active
// elsewhere, so that error can't mean missing. Only dsh's own "not found" counts as missing, and not when dsh's list
// names the conversation. The list can't show a conversation is gone: one another running dsh holds unsaved may be
// left out, so an unlisted conversation is still reopened. Anything else leaves the resume unconfirmed.
const method = capabilities.sessionCapabilities?.resume ? "session/resume" : capabilities.loadSession ? "session/load" : "session/resume";
const listed = capabilities.sessionCapabilities?.list ? await listsConversation(resumeRef) === true : false;
try {
await peer.request(method, { sessionId: resumeRef, cwd, mcpServers: [] });
} catch (error) {
if (error.code === ACP_RESOURCE_NOT_FOUND && !listed) {
writeLaunchRecord({ error: "missing", producer });
return fatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`, 3);
}
return fatal(`dsh couldn't reopen conversation ${resumeRef} (${method}: ${error.message}); the resume is unconfirmed and no new conversation was started`, 4);
}
state.sessionId = resumeRef;
} else {
try {
state.sessionId = (await peer.request("session/new", { cwd, mcpServers: [] }))?.sessionId ?? null;
} catch (error) {
return fatal(`dsh couldn't open a conversation (${error.message})`, 1);
}
if (!state.sessionId) return fatal("dsh opened a conversation without naming it", 1);
}
writeLaunchRecord({ nativeRef: state.sessionId, producer });
say(`[openrig-dsh] ready · ${producer} · conversation ${state.sessionId}`);
say(`[openrig-dsh] DSH_HOME ${home} · DSH_PERMISSION_MODE ${agentEnv.DSH_PERMISSION_MODE}`);
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
for (const ask of state.asks) ask.end("exited");
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3_000).unref();
});
}
process.on("SIGINT", abort);
const framer = new InputFramer();
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdout.write("\x1b[?2004h");
tty = true;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
for (const event of framer.push(chunk)) {
if (event.type === "echo") process.stdout.write(event.text);
else if (event.type === "erase") process.stdout.write("\b \b");
else if (event.type === "abort") { process.stdout.write("\n"); abort(); }
else if (event.type === "block") { process.stdout.write("\n"); submit(event.text); }
}
});
} else {
createInterface({ input: process.stdin }).on("line", (line) => {
if (line.trim() === "/abort") abort();
else if (line.trim()) submit(line);
});
}
if (firstInput !== null) submit(firstInput);
else postIdle("idle_heartbeat");
}
const invoked = (() => {
try { return process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null; } catch { return null; }
})();
if (invoked === import.meta.url) {
main().catch((error) => { console.error(`openrig-dsh-runner: ${error.stack ?? error}`); process.exit(1); });
}
@@ -1,8 +1,7 @@
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
# the example launcher), before the seat's first start.
#
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
# the gateway's environment. For each session it swaps
# Hermes runs every tool command in one gateway per machine, with the gateway's environment. For each session it swaps
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
@@ -5,9 +5,8 @@ command: openrig-hermes-launch # no model, provider or --max-turns here: they
maintainer: Your name or team
limits:
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
- closing a seat's pane detaches it; Hermes finishes any accepted turn, and its gateway exits when idle
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -24,21 +24,6 @@ if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
# resume this launcher applies no OpenRig model.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
case "$OPENRIG_MODEL" in
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
else
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
fi ;;
esac
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
@@ -1,5 +0,0 @@
# OpenShell reference driver
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
@@ -1,100 +0,0 @@
#!/bin/sh
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
# folder, and takes the first input as its first prompt;
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
# an id can open another session whose id starts the same way, so neither is used to resume.
set -eu
pi="${OPENRIG_PI:-pi}"
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
sessions="$state/sessions"
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
# login and settings in this folder, before its first start.
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
case "$PI_CODING_AGENT_DIR" in
"~") PI_CODING_AGENT_DIR="$HOME" ;;
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
esac
export PI_CODING_AGENT_DIR
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
set -f
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
case "$skill" in
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
esac
if [ -d "$skills_from/$skill" ]; then
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
else
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
fi
done
set +f
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
valid_id() {
case "$1" in
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
esac
}
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
set --
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input="${first_input%.}"
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
if ! valid_id "$id"; then
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
exit 2
fi
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
node="${OPENRIG_PI_NODE:-node}"
if ! command -v "$node" >/dev/null 2>&1; then
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
exit 1
fi
found="" count=0
for file in "$sessions"/*_"$id".jsonl; do
[ -s "$file" ] || continue
if head -n 1 "$file" | "$node" -e '
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
catch { process.exit(1); }
});' "$id"; then
found="$file" count=$((count + 1))
fi
done
if [ "$count" -ne 1 ]; then
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
exit 1
fi
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
if ! valid_id "$OPENRIG_CONVERSATION"; then
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
exit 2
fi
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -1,15 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format: 1
name: pi-tui
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer: Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
names_conversation: true
first_input: true
+4 -260
View File
@@ -4,36 +4,6 @@ A community harness is a coding harness an outside contributor maintains. You in
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
it never reads an unsupported capability as idle, working, ready or verified.
| Capability | Supported | How it works, or what you see instead |
|---|---|---|
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
@@ -51,8 +21,6 @@ launch_result: true # optional: the launcher reports the conversatio
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
startup_assisted: true # optional: startup submission needs a person's check (below)
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
@@ -149,45 +117,6 @@ For a reported conversation, OpenRig also says so plainly when:
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## Runner presets (`runner: true`)
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
without `launch_result` is refused.
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
field existed reads as plain.
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
unavailable.
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
Enter as one prompt.
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
- the seat, as `sessionName` and `nodeId`;
- `runtime`, the preset's name;
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
- an increasing `sequence`, and `occurredAt`;
- `hookEvent` and `subtype`, as in the table.
| The runner posts | When | The seat reads |
|---|---|---|
| `UserPromptSubmit` | it gave the agent a prompt | working |
| `active` or `PreToolUse` | the agent is working | working |
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
| `Stop` / the turn's stop reason | the turn ended | idle |
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
@@ -199,9 +128,7 @@ Agent's CLI.
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
the other's running). It runs them with that gateway's
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in one gateway per machine, with that gateway's
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
@@ -212,8 +139,7 @@ Agent's CLI.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
gateway has gone before relying on it. The preset says so in its limits.
accepted, and the seat's gateway exits once idle. The preset says so in its limits.
Copy them and maintain them as your own.
@@ -225,18 +151,13 @@ OpenClaw's CLI.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
@@ -258,59 +179,8 @@ CLI.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
## An example runner: dsh
[`community-harness-examples/dsh/`](community-harness-examples/dsh/) has a runner preset (`runner: true`) and a small runner
for DeepSeek Harness, which speaks ACP on stdio and has no terminal UI of its own. It's written for dsh 0.2.1-rc.2 and
checked only against a stand-in agent.
- **The runner:**
- it opens the conversation and writes the launch record;
- it turns each submitted block into one prompt;
- it reports turn boundaries as activity hooks;
- it cancels on `/abort` or Ctrl-C, as the [runner contract](harness-runner-contract.md) describes.
- **Permissions:** it sends each request to OpenRig, which asks the default person or the seat's override.
- **When no one can be asked, or OpenRig can't answer,** it permits with dsh's own allow option (`allow_once`, else
`allow_always`), which is OpenRig's default. `allow_always` creates a standing grant in dsh.
- **It refuses and holds needs input** only after `--deny`. A person's no when dsh offered no reject option, or a
request OpenRig says isn't the current occupant's, is answered cancelled.
- **Each seat gets its own dsh home,** with dsh's session-log and plugin-list uploads to DeepSeek turned off. Give that
home credentials, or put a provider key in the seat's environment.
- **Resume isn't declared.** The runner reopens a conversation by the method dsh advertises, and reports one missing only
on dsh's own not-found error. Declare `resume: true` only after a real dsh has reopened a conversation and refused a
missing one.
Its [README](community-harness-examples/dsh/README.md) has the setup and a check you can run without a model. Copy both
and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
@@ -322,129 +192,3 @@ The contributor-owned launcher reads the file and uses its harness’s supported
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Pass the seat's declared model at launch
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
model flag or config key.
OpenRig keeps three things apart:
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
refuses is the harness's own error.
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
out `--model` doesn't show that it keeps its earlier model.
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
mkdir -p "$PI_AGENT_DIR/skills"
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
```
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
For example:
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
counts as guidance, which a community seat doesn't take.
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
argument in your Pi version.
- `first_input` carries text, not files: it doesn't install the skills.
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
made with `handoff-and-complete`, whose next row names it as its source.
## Write your own
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
([above](#what-a-community-harness-gets)).
1. **Choose how the conversation is named.**
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
launcher writes the record.
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
`exec`, and does nothing else.
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
error (or report `error: "missing"`) instead of creating one.
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
conversation.
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
- List what doesn't work under `limits`.
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
yours.
## The promotion test
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
What the maintainers look at:
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
- a seat starts and takes its first turn (`rig up`);
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
4. **The built-in runtimes are unchanged.**
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
There's no score or threshold. The maintainers weigh the evidence.
+11 -40
View File
@@ -1,8 +1,7 @@
# Edge Types Reference
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
The fallback escalation section and deferred-target exceptions below describe #605.
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
---
@@ -17,7 +16,8 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
`spawned_by` and `can_observe`):
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
@@ -25,13 +25,13 @@ Five edge kinds are accepted by both the pod-aware and legacy flat-node validato
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
| `escalates_to` | yes | no | Source escalates to target |
## What Edges Actually Do Today
### Launch ordering
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
@@ -46,42 +46,13 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
@@ -93,7 +64,7 @@ node, in creation order, whose session is running, or else the first node with a
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to` or fallback `escalates_to`)
daemon path that follows `delegates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -157,7 +128,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
@@ -1,138 +0,0 @@
# Execution and platform integration contract — draft
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; publishing it does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
| Identity or handle | Meaning and responsibility |
|---|---|
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
## A minimal composition using existing surfaces
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
```yaml
pods:
- id: dev
label: Development
members:
- id: coordinator
agent_ref: local:agents/coordinator
profile: default
runtime: claude-code
- id: worker
agent_ref: local:agents/worker
profile: default
runtime: example-contained-claude
```
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
```yaml
format: 1
name: example-contained-claude
maintainer: Integration author
command: ./scripts/launch-contained-worker
launch_result: true
resume: true
startup_assisted: true
limits:
- Requires an explicitly prepared environment binding and guest projection.
- Native resume is qualified only for the documented retained-state recipe.
- Viewer closure does not implement turn cancellation or resource deletion.
```
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
## Placement and launch receipts
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
- canonical seat, harness version and native resume-reference type;
- provider/host/workspace, immutable environment ID and execution generation;
- image/policy versions, requested resources and observed readiness;
- inner working directory, private native-state location and projection manifest;
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
- lifecycle operations supported, completion observations, pending effects and errors.
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
## Project files and credential references deliberately
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
## Own only the effects assigned to the integration
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
Describe these effects independently:
| Operation | Required distinction |
|---|---|
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
## Resume exactly, or return an honest disposition
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
## Export results before teardown
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
## Proposed generic platform seam — not implemented
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
```text
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
observe(binding, execution) -> observedState | unsupported | unconfirmed
export(binding, artifactRequest) -> publicationReceipt
shutdown(binding, execution, selectedEffect) -> effectReceipt
```
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
-165
View File
@@ -1,165 +0,0 @@
# Gateway participants: several people, shared channels, organizations and policy
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
# People: the second and later ones are allowed now
rig gateway human add sarah --display-name "Sarah" \
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
# A shared channel
rig gateway channel add acme-customers --display-name "Acme customers" \
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
# See everyone, with organization, groups, the default and each participant's policy
rig gateway participants
rig gateway participants --organization acme --json
rig gateway human show sarah
```
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
non-terminal rows are addressed to it unless you pass `--force`.
## Policy is context, not enforcement
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
## The default person
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
go to:
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
2. otherwise the only registered person, so a one-person install needs no choice;
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
waits, and the others name the command to run.
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
## Shared-channel conversations
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat, without registering
each of them.
**Setup:**
1. Invite the OpenRig Slack app to the channel.
2. Register the channel: `rig gateway channel add acme-customers --display-name "Acme customers" --binding
"slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme --policy-file ./acme-customers.md`.
3. Send its new messages to the support seat: `rig slack channel-map set support-rig C0ACME --inbound
support@support-rig`. Without a map, they go to the inbound destination.
A channel works on its own: no registered person is needed for it to converse.
**What the seat sees:** each message lands as a row from the channel (`acme-customers@external`). Its body ends with a
labelled block: the channel, its organization and groups, who wrote (the Slack user, marked as not a registered person),
and the channel's effective policy. The row is tagged `participant:acme-customers`, `shared-channel` and
`slack-sender:<user id>`. A registered person writing in the channel stays themselves; the block only says where they
wrote.
The channel's policy is the labelled block OpenRig adds at the end of the row, after the message. Anything above it is
the sender's own words, even if it looks like policy. `rig gateway participants --json` is the source.
**Answering:** reply into the customer's thread with an update to the channel:
```bash
rig queue create --destination acme-customers@external --human-intent update --reply-to <their row's id> \
--summary "Your invoice" --body "It's on your account page."
```
A later update with the same `--reply-to` lands in the same thread. An update without `--reply-to` posts to the channel
on its own. Posts to a channel mention nobody.
**What a channel can't do:** its messages are correspondence. They never answer, approve, close or resume anyone's ask.
You can still send an ask to the channel; the queue accepts it with a warning that nothing there will settle it, so read
the replies and close it yourself. Its questions show in the channel as a list, without buttons, and ask for a reply in
the thread.
## Where it's stored
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
`humans.generated.yaml`. Nothing new is written into them.
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
The commands above report the problem, and with several people no default is chosen until it's fixed.
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
remove` also removes the person's placement, and the default if it was them.
## Upgrading
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
their addresses, and no new file is written until you use one of the commands above.
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
user's authority, which only a current TUI sends.
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
## Rolling back to 0.7.2 or earlier
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
- **every registered person still loads**, and each can still answer their own asks from Slack;
- **another person's reply is guarded less on 0.7.1 and 0.7.0:** there, a different person's Slack reply in the thread
of a question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as
an answer row (it still can't close the ask);
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
has no default person, and pairing needs `--human`;
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
answered by the primary user keep their notes;
- **shared channels are unknown:** their customers' messages are refused again, as from any unregistered sender, and
rows addressed to a channel become undeliverable on the older version;
- **organizations, groups and policy aren't shown.**
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
+10 -63
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -331,16 +331,6 @@ every seat while those seats still run the sessions it started, reports that
restore's outcome instead of starting another. Explicitly repeating the saved
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
`rig start --last` restores a rig unless every seat it launched was stopped on
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
once, by rig id when names are shared; it records the stop, and `--last` then
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
bring it back with `rig up kernel --existing`.
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
restore to finish, then replaces only the named seats that still hold the exact
sessions startup launched. Other seats keep running. An operator's conversation
@@ -509,12 +499,7 @@ rig terminal open saved:kernel --provider herdr --json
rig terminal open saved:kernel --provider cmux --json
```
Use `rig terminal status --json` to inspect provider availability, liveness and the
daemon's effective tmux server. To keep managed seats out of your personal tmux
session list, opt into [an isolated server](tmux-servers.md) with
`rig config set terminal.tmux_server isolated`; existing live seats defer the
switch until they are stopped and the daemon restarts.
Use `rig terminal status --json` to inspect provider availability and liveness.
To keep rigs in separate Herdr sessions, select one explicitly:
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
`--session default` selects Herdr's default session; omitting the option keeps
@@ -713,11 +698,9 @@ partial terminal view is not a healthy team. Each `--provider herdr` open adds
a new workspace, numbered when the name is taken; return to the one already open
when you want to preserve it. From inside Herdr, `rig terminal open <view> --window`
focuses a matching space instead of adding one. Herdr's own status for a seat can
read idle or unknown while OpenRig shows it waiting (`needs-input`), for example in
a narrow tile that wraps Claude's footer, or in a tile that shows the inner tmux
status line (with `terminal.status_bar` on, or for a seat session OpenRig
adopted rather than created). Use OpenRig's view or the Feed to find waiting
seats. This is terminal integration, not native plugin enrollment.
read idle or unknown while OpenRig shows it waiting (`needs-input`); use OpenRig's
view or the Feed to find waiting seats. This is terminal integration, not native
plugin enrollment.
For team views with interactive panes, the open result distinguishes the dashboard
overview from the team's lead conversation and asks whether you can see the team.
@@ -853,53 +836,17 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when all of its seats were lost; check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
**When the shared kernel dashboard is missing.** The dashboard runs in the
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
queue worker does not also restore an unselected terminal. Read
`rig ps --nodes --rig kernel --json` and use the terminal's registered
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
in the commands below with that name. Check the actual pane with
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
that its tmux session is alive.
A daemon or transport error is not proof that the terminal is absent.
- If only the viewing window closed and the TUI is still running, reopen it
with `rig terminal open saved:kernel --window`.
- If the TUI exited but its terminal shell remains, run `rig tui` once in that
existing pane. A fresh seat is unnecessary.
- If the terminal session itself is absent, start only that existing seat:
```sh
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
```
The terminal has no agent conversation to resume. Its fresh-start action
runs `rig tui` after the shell is ready; sibling agent sessions stay
unchanged. This command intentionally omits `--stop`, so it refuses to
replace a live occupant. Check the result before repeating it, then run
`rig capture "<dashboard-session>"` to confirm the TUI and
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
is running, open its view with `rig terminal open saved:kernel --window`.
Opening `saved:kernel` places a view of existing sessions; it cannot create a
missing terminal. Whole-kernel recovery is for the all-seats-lost case in the
table, and `rig up kernel --existing` refuses a kernel with live sessions.
A retained `excluded_from_subset` reason may come from an earlier subset
restore; inspect its event before attributing it to the latest reboot.
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
step) and `rig doctor` (`tmux_capture` in `rig doctor --json`) run this check for you on a throwaway server and name the
fix when capture fails; they test what tmux does, not its version. To run it by hand, without touching your own
sessions:
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. To check your tmux on
a throwaway socket, without touching your own sessions:
```sh
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
-389
View File
@@ -1,389 +0,0 @@
# Harness and runner contract (DRAFT, published with 0.7.6)
> **Status.** This is a draft for contributors.
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
> (sections 2-4).
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
>
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
the same harness can run in many places, and one place can host many harnesses.
## 1. Plain preset or runner preset?
| | Plain community preset | Runner preset (`runner: true`) |
|---|---|---|
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
terms in 0.7.6. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
formats or loaders that exist.
## 2. Declare it: what the preset says, fixed at launch
```yaml
# $OPENRIG_HOME/community-harnesses/dsh.yaml
format: 1
name: dsh
command: dsh-seat # your runner; it starts the agent itself
maintainer: <you>
runner: true # since 0.7.6
launch_result: true # required with runner
resume: true # only after you've checked a real reopen and a refused missing one
limits:
- permission requests go to a person through OpenRig (section 7), and are permitted when no one can be asked
- a prompt whose whole text is /abort can't be sent
- fork unsupported
```
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
moment. Editing the preset changes the next launch, not a running seat.
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
- **The other fields keep their existing meanings:**
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
## 3. Identity: report the conversation once, in the launch record
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
`session/resume`).
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
```
or the agent's own word that it doesn't exist:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
```
- **`producer` is free text** for your versions. There are no structured version or capability fields.
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
- **No record means attention.** For a runner preset:
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
resume reads unconfirmed (section 8).
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
## 4. Activity: report turn boundaries
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
```
| When | `hookEvent` / `subtype` | The seat reads |
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
tied to a particular `rig send`.
- **The generation attributes a post; it isn't authentication.**
- A post carrying an earlier occupant's generation never counts for the live seat.
- The hook token is one local token, so any process holding it can post for any seat.
- **A seat activity grant** is what a runner in another environment, such as a sandbox guest, receives instead of the
local token. It arrives in the same `OPENRIG_ACTIVITY_HOOK_TOKEN`, with an `OPENRIG_URL` the guest can reach, and it
speaks only for its own seat and launch generation:
- with a grant, the generation is authenticated, and you may leave out `nodeId` and `generation`;
- a post for another seat is refused with `403 activity_grant_wrong_seat`;
- a post claiming another generation, or from an occupant that has since been replaced, is refused with
`409 activity_grant_stale`;
- a post before the launch has registered its occupant is refused with `409 activity_grant_pending`;
- a post with a revoked grant is refused with `401 activity_grant_revoked`, including a post whose grant was revoked
while its body was arriving.
A refused post records nothing, so the seat's activity stays as it was, or unknown.
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
posts only, and the main port's other routes don't check it.
## 5. Input: one submitted message is one prompt
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
existing built-in runtimes or require every possible runner protocol to use ACP.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
- **Never split a block,** and never act on lines inside it.
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
contain.
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
- **Limits to keep and to list under `limits`:**
- a prompt whose whole text is `/abort` can't be sent as a prompt;
- a Ctrl-C byte inside a paste also aborts;
- without a TTY, input is read line by line;
- this isn't a binary-safe channel.
## 6. Cancel: requested isn't the same as done
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
## 7. Permissions: a person answers, and the default is permit
### The first version: refuse every request (history; still what `--deny` does)
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
`{"outcome": {"outcome": "cancelled"}}`).
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
turn's `Stop` or idle beats while holding it.
- **It clears** when your next prompt is accepted, or when an operator clears it
(`rig seat clear-attention <session> --reason "…"`).
- **Never leave a request pending.**
- **What it couldn't do:**
- answer from the seat's permission setting;
- take answers typed into the pane;
- route an approval to another seat.
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.8,
replaces this as the default.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
### The answered path (0.7.8)
**Only requests the agent actually makes reach anyone.** The harness's own permission mode decides that, and a harness
that runs without asking gains no approval step. Ordinary conversation and `rig send` are unchanged.
**What happens to a request, in the person's terms:**
| Situation | What happens |
|---|---|
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened. That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
**What the ask shows:**
- the seat and the conversation;
- the tool call, as the agent described it;
- why this person was asked;
- every option the agent offered, each with its native meaning. `allow_always` means the agent's harness remembers
the choice and allows similar requests without asking. OpenRig doesn't record or revoke that.
**How a person answers, and what the agent gets:**
| The person… | The agent gets |
|---|---|
| clicks an option: the asked person, any other registered person, or the primary user in the TUI | that option |
| replies with just a yes ("yes", "ok", "allow", "go ahead" …) | the agent's allow (`allow_once`, else `allow_always`) |
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow |
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
`stale`, and one withdrawn at the turn's end reads `withdrawn`. Neither can take a later answer.
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
agent as a prompt.
**The runner's protocol:**
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
grant as section 4:
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "generation": "<OPENRIG_OCCUPANT_GENERATION>",
"nativeRef": "<the conversation>", "turnId": "7", "requestId": "<the agent's JSON-RPC id>",
"toolCall": {"title": "Edit src/app.ts", "kind": "edit"},
"options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
{"optionId": "reject", "name": "Reject", "kind": "reject_once"}],
"occurredAt": "2026-10-30T12:00:00Z"}
```
- **A person is asked:** the reply is `{"ok": true, "askId": "…", "created": true}`. OpenRig records the seat's
`permission_prompt`, so it reads needs input. Don't post it yourself.
- **No one can be asked:** the reply is `{"ok": true, "state": "permitted", "optionId": "…", "reason": "…"}`.
Deliver that option.
- **`requestId` and `turnId`** may be strings or numbers. Pass the agent's id unchanged: a number is read as its
decimal string, so `7` and `"7"` are the same request.
- **Resubmitting** the same generation, conversation and request id returns the same `askId`.
2. **Read** the answer with `GET <base>/api/activity/permission-requests/<askId>?waitSeconds=25` (25 at most), and read
again until the agent's turn ends.
- **The `state`** is `pending` (read again, don't cancel), `answered` or `permitted` (each with `optionId`),
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
`stale`.
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
refused (`409 activity_grant_stale`), even during a read's wait.
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
person is still waiting. A refusal of the read itself follows the refusals below.
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
- A later answer isn't sent.
- A relaunch makes the earlier occupant's open requests stale on its own.
**Refusals, at submit or while reading.** One rule covers both. Cancel only a request that certainly isn't the current
occupant's live one, and hold no attention. Anything that may still be a live request permits, as when OpenRig can't
answer. Only the seat's opt-in refuses and holds attention.
| Code | Status | Meaning | The runner |
|---|---|---|---|
| `denied_by_seat` | 409 | The seat's operator opted in to refusal (`--deny`). | refuses and holds attention |
| `options_unsupported` | 422 | No one can be asked, and the agent offered no allow option to permit. | refuses and holds attention |
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
permission nobody chose. That covers:
- an OpenRig without this path, which answers 404;
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
times first);
- a refusal the table marks "permits", and any status or code this section doesn't list.
**Limits in 0.7.8:**
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
needs input, until the agent's turn ends.
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
change.
- **Permits and answers aren't recorded beyond the runner's pane and the ask itself.** A permit with no one to ask
opens no ask.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
advertises: ACP's `session/load` (capability `loadSession`), ACP's `session/resume` (capability
`sessionCapabilities.resume`, which dsh `0.2.1-rc.2` uses), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
- **Telling "missing" from "won't reopen":** an agent may answer one error for both. dsh `0.2.1-rc.2` answers `-32602`
for a conversation it doesn't have and for one already active elsewhere. So don't read an error's code or text as
missing unless the agent defines it that way, as ACP's not-found `-32002` is.
- **Report missing only on the agent's own not-found,** and leave anything else unconfirmed.
- **When the agent advertises `sessionCapabilities.list`,** its `session/list` can show that a conversation exists:
one it lists but won't reopen is unconfirmed, even on a not-found. Follow `nextCursor` (an opaque string, possibly
empty), and treat a page you can't read as deciding nothing.
- **A list doesn't show that a conversation is gone,** unless your agent guarantees it lists every conversation. An
agent may leave out one that another running process holds unsaved; dsh `0.2.1-rc.2` can. So still try the reopen
for a conversation the list doesn't name.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
| The same conversation reopened | the same `nativeRef` | resumed |
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
- **Never start a new conversation on a resume.**
- A conversation saved under another harness's runtime is never handed to your launcher.
## 9. Context, model and shutdown: whose job is what
| Responsibility | OpenRig does | Your preset or runner does |
|---|---|---|
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
## 10. What counts as evidence
- **Keep three things separate:**
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
that `--verify` recognises it.
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
marked done, or an artifact written.
- **Your own tests** should run against a fake agent that covers each section above. The
[runner conformance check](runner-conformance/README.md) does that for you: point it at your runner, and it reports
which promises your runner keeps.
- **The useful real check** for a maintainer:
- a seat starts and reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled;
- a resume reopens the same conversation, and a missing one is refused, never replaced.
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
compatibility evidence for the versions you name, not certification.
## Contributor checklist
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
`subtype`. They're sent in order and never block the agent.
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
- [ ] Every permission request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
person's, or OpenRig's permit. When OpenRig can't answer (404, unreachable, or a refusal section 7's table marks
"permits"), the runner permits with the agent's `allow_once`, else `allow_always`. A refusal that says the request
isn't the current occupant's, or a `refused` answer, means answer `cancelled` and hold nothing. Only
`denied_by_seat`, or no allow option to permit, means refuse at once, show it, and hold needs input until the next
accepted prompt.
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
unconfirmed.
- [ ] Unknown agent requests get `-32601`.
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
## Open questions
1. **Answered:** which method and capability does dsh advertise for reopening a session, at a pinned version? dsh
`0.2.1-rc.2` advertises `sessionCapabilities` `{close, list, resume}`, without `loadSession`, and reopens with
`session/resume` (section 8). This was observed with a stand-in model, not a real one.
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
with queue tasks? 0.7.8 routes them to the default person, or to a person chosen for the seat (section 7). A chosen
seat is still open.
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
harness?
+3 -6
View File
@@ -139,9 +139,7 @@ issue's current status; a similar symptom alone is not a diagnosis.
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
[#273](https://github.com/mvschwarz/openrig/issues/273).
- Claude usage limits may not be detected when neither a status line window nor a current
`StopFailure/rate_limit` hook is available: [#99](https://github.com/mvschwarz/openrig/issues/99).
See [Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection) for the inputs and retry behavior.
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
by the team.
@@ -149,9 +147,8 @@ issue's current status; a similar symptom alone is not a diagnosis.
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
- Kernel seats fail within a second of the first start on a distribution tmux whose server exits on `capture-pane`
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). `rig doctor` checks for
it (`tmux_capture`); the manual check and recovery are in
[Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). The check and recovery
are in [Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
-114
View File
@@ -1,114 +0,0 @@
# Hosted members: a tool outside the terminal on your team
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
## Set one up
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
[Rosters](rosters.md)).
3. **Issue its grant:**
```sh
rig mcp token add grok --seat grok@research --rig research
```
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
that rig's seats and work.
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
MCP clients aren't browsers.
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
## How it works its rows
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
- **The member works it** through the MCP tools:
- `rig_queue_list` with `owned` lists its rows;
- `rig_queue_claim` takes one;
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
- **It can send work too:** `rig_queue_create` to its rig's seats.
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
## Asking a person while keeping the work
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
row to the member in progress.
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
question closes, the member's row returns to pending.
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
unlimited delivery.
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
member, so it checks its rows again.
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
## Ring it when work lands
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
entry. The entry holds secret names, never values:
```json
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
```
- **The secrets:**
- `url_ref` names the secret holding the webhook URL.
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
- **The ring:** when a row lands for the member, the daemon POSTs
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
@@ -1,83 +0,0 @@
# Pi tool-selection recovery
Use this when a live Pi conversation has fewer active tools than its operator
currently intends. This does not establish why the selection changed or that
stock Pi/OpenRig lost tools. The motivating report used a customized Pi 1.4.0
deployment; it is not a stock cross-platform reproduction.
## Diagnose before changing the selection
Confirm the exact seat, occupant and conversation, then use an idle boundary.
Preserve pending work and reconcile unknown writes before any replacement.
Keep four things separate:
- **Configured:** the effective merged `defaultTools` setting. Do not infer it
from one settings file; retain any unresolved patterns or modifiers as such.
- **Currently intended:** the tools the operator wants enabled now, including
deliberate exclusions. Configuration alone does not establish that intention.
- **Registered:** names the current Pi runtime knows about.
- **Active:** names returned by `getActiveTools`, which can differ from both.
A live RPC connection does not prove the tools are usable. Record observation
time and conversation binding; an unavailable query stays unknown. Reloading
unchanged settings can retain session-disabled tools by design, so a reload
that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
For RPC seats, an optional [community-maintained helper][helper] provides
read-only status and an explicit command that adds only named intended tools.
It is maintained outside OpenRig, is not bundled, and this link is not a support
claim. Loading it also registers the agent-callable `openrig_runtime_status` and
`openrig_slots` tools; the latter reads a snapshot of the bound rig's agents and
active queue items. It can add a prompt section when a guidance file is set.
Review its [shared implementation][runtime] and
[setup/capability limits][setup] before opting in through Pi's per-seat extension
configuration. Keep the two helper files together; do not install the separate
RepoStew application just to use the neutral bridge. Verify what the target
session actually loaded after the supported activation path.
With that helper already loaded, these are **Pi extension commands**, not new
`rig` commands:
```text
/openrig-status
/openrig-restore-tools read
```
For an agent OpenRig drives over RPC, send each extension command with
`rig send <agent> --raw "<command>"` so it arrives without a messaging envelope;
the usual interactive-prompt guard still applies.
The second command means that `read` is intended now. It does not restore every
configured default. If `bash` was deliberately disabled, leave it disabled
unless the operator explicitly intends to add it too. With no names, this helper
refuses to change the selection.
## Verify the recovery
1. Establish the explicit current intended names. Reject unregistered or hidden
requested tools before changing anything; do not infer replacement names.
2. Preserve current active extras: `setActiveTools` **replaces** the selection,
so pass the union of current active names and only the requested names. It can
ignore names it cannot activate; sending the command is not proof of recovery.
3. Read `getActiveTools` back. Retain a before/after receipt with the intended
names and any missing names, then make a harmless actual call to a recovered
tool within the existing task authority. Listed-active is not proof of usable
permissions or working execution.
This changes tool selection, not permission policy, task authority or conversation
identity. It does not automatically persist a new branch selection; verify again
after branch navigation or reload. If the runtime cannot support or verify this
in place, report that capability gap and follow its supported replacement path
with retained work. Do not manufacture a fresh conversation or claim that another
harness has Pi's capabilities.
[helper]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-openrig.js
[runtime]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-runtime.js
[setup]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/references/harnesses/pi.md
@@ -120,11 +120,6 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
## 6. Submit it to openrig.dev/rigs
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
(see [section 1](#1-lay-out-the-folder)).
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
+1 -13
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.2`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
@@ -193,18 +193,6 @@ it as the step to finish: sign in, then continue at step 3.
otherwise, or when the width can't be measured, the dashboard is on its own page. `saved:kernel` in
`rig terminal open saved:kernel` is the name of this saved view.
## Learn it in this order
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
3. **One command:** `rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
lists every command.
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
your version.
## More
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
+7 -293
View File
@@ -290,77 +290,6 @@ managed-launch path. Other version pairs and native enforcement need their own c
The launch still uses inline JSON, subject to the operating system's argument-size
limits.
### Per-member Codex permissions
Codex members can record their own sandbox and approval choice, even when they share a project folder:
```yaml
codex_permissions:
sandbox: workspace-write # workspace-write or read-only
approval: never # never, on-request or untrusted
network_access: false # workspace-write only
writable_roots: ["/abs/lease"] # workspace-write only; absolute paths
exclude_tmpdir_env_var: false # workspace-write only
exclude_slash_tmp: false # workspace-write only
```
OpenRig emits this envelope as explicit `-s`, `-a` and `sandbox_workspace_write.*` flags on
every managed launch and resume of the seat (fresh, both resume paths, restore and same-seat
handover), so a named `codex_config_profile` or a policy posture doesn't override those fields.
Omitted workspace-write fields are recorded as `false` or `[]` and emitted as such. Full access
is not an envelope: it stays the `full_bypass` mode. The envelope is the seat's explicit choice,
so team and kernel launch defaults don't apply to it, and status names the policy posture or
ambient `OPENRIG_YOLO` it replaces. With workspace-write, OpenRig still adds its queue-state root
and, on a fresh launch, git metadata folders; with read-only it adds no writable roots. It adds
no network setting of its own. Codex's implicit roots (the working directory and temporary
folders) still apply: this is a native sandbox and approval selection, not a complete
filesystem or credential-isolation guarantee. OpenRig's tests prove these launch flags, not
that Codex enforces them, so check the seat's actual behaviour before relying on a narrower
envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after a downgrade
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
runtimes reject it.
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
audited permission command:
```sh
rig seat set-permissions dev-impl@team --codex-permissions envelope.json --reason "Narrow the implementer"
rig seat set-permissions dev-impl@team --codex-profile lease-impl --reason "Use the lease profile"
rig seat status dev-impl@team --json
```
Choose one of `--mode`, `--rules`, `--codex-permissions` or `--codex-profile` per call. A seat
keeps one explicit permission choice: setting an envelope replaces a recorded mode, and setting
a mode (including `inherit`) clears the envelope, in the same audited write that names both.
A runtime switch keeps the envelope while the seat runs Claude Code and applies it again when
`rig seat set-runtime` brings the seat back to Codex. A switch back to Codex with an explicit
`--permission-mode` replaces the envelope when the switch commits, and the switch's plan and
event name it. A switch that doesn't commit leaves it in place.
A profile name is separate from the envelope and from approval: a resumed conversation keeps
its saved approval unless an envelope sets one. `--codex-profile inherit` clears the profile.
The profile command checks the profile in the config home this seat's launches use: the seat's
own `CODEX_HOME` when its `env:` declares one, else the daemon's explicit `CODEX_HOME`. There it
reports an absent file (Codex loads it as an empty layer, so base config applies), records the
file's hash so a later check can report a change, and refuses a profile that fails to load,
keeping the previous one. When neither sets a `CODEX_HOME`, a seat's pane keeps its own, and the
command reports the profile as unverified rather than checking a different home. Every launch
still runs the native profile check.
Updates leave the running process and history unchanged; they apply at the next separately
authorized managed launch or resume. Restore reads the seat's current record, so an older
snapshot doesn't bring back a replaced profile. A fork copies the source's current profile and
envelope independently. Export contains the current record. A new generation from `rig up
<spec>` starts from the spec. An invalid or unreadable envelope refuses that seat's launch, fork
or export with a repair message rather than launching broader. Without the field or a recorded
update, launch arguments are unchanged.
**Downgrade:** an older OpenRig keeps the stored envelope but doesn't read it, so its launches
of the seat use the earlier posture decision, which can be broader. This is not
restriction-preserving. Keep a copy of the intended envelope and establish equivalent native
restrictions before using an older managed-launch path. Native enforcement must be checked on
the Codex version and configuration being used.
### Team launch defaults
A non-kernel Claude or Codex seat with no authored permission policy, explicit
@@ -511,9 +440,9 @@ pods:
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
@@ -526,105 +455,6 @@ only the stored `env:`. Seats may share a home. Live usage refresh (`rig provide
daemon's own logins; reading each seat's home would need a native reader per home. OpenRig doesn't copy logins or conversation history between homes, so a conversation saved under a seat's earlier home
doesn't resume after the seat moves to another one. The `openrig-user` skill has the setup steps.
**A seat's own subscription token.** A Claude member can start on a prepared subscription token from its first launch,
with no conversation to switch first. Name the token file beside its config dir:
```yaml
- id: dev
runtime: claude-code
env:
CLAUDE_CONFIG_DIR: /abs/claude-dev
claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
```
The label and the absolute path are stored on the seat when the rig is created, as `rig provider switch --seat <seat>
--to-account <label> --token-file <path>` stores them. Its launches run the same token step, which reads the file as
Claude starts; a launch path that can't run it (a pane shell other than a POSIX one, for example) starts without it and
warns. The file's contents never pass through OpenRig: validation doesn't read it, and they don't reach the spec, the
seat's record or the launch command. A missing, empty or unreadable file doesn't refuse the spec; the seat starts on its
config dir's stored login (or a token the daemon gives every seat) and says so. A launch also warns when a setting Claude
Code ranks above the token would be used instead: an `apiKeyHelper`, or an outranking `env` entry in the user, project
or local settings. Managed settings aren't inspected. `rig export` writes the seat's current reference, except for a seat
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
while staying in its rig:
```sh
rig provider switch --seat dev-impl@my-rig --to-account api-account \
--account-home /home/me/accounts/codex-api --model gpt-5.5 --reason "Use the API budget" --dry-run
rig seat set-runtime dev-impl@my-rig --runtime claude-code \
--account-home /home/me/accounts/claude-api --model claude-sonnet-4-6 --reason "Codex limit"
```
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
Claude Code before the successor starts.
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
model, profile and permission selection together. The seat keeps its address, edges, queue rows and claims. If OpenRig
refuses the switch before cutover, the predecessor's configuration stays unchanged.
For a Claude plan authenticated with `setup-token`, supply the token file with the home:
```sh
rig provider switch --seat dev-impl@my-rig --to-account claude-plan \
--account-home /home/me/accounts/claude-plan --token-file /home/me/credentials/claude-plan-token \
--reason "The plan reset"
# Across runtimes, use set-runtime with --account-home, --token-file and --account-label.
```
To return to the seat's configured login:
```sh
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset" --dry-run
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset"
# Across runtimes: rig seat set-runtime dev-impl@my-rig --runtime claude-code --clear-account-home --reason "Return to configured login"
```
Clearing the selection also starts a successor with a fresh conversation and a handover. OpenRig removes the saved
account-home override and its token reference only after the successor confirms. The successor starts with the member's
or rig's configured home, falling back to the daemon's selection and then the native default, as an ordinary launch does.
If there was no account-home override, OpenRig preserves any unrelated legacy per-seat token. Clear uses the current
configuration; it does not remember a prior login or restore old credentials. Use clear on its own, without
`--to-account`, `--stored-login`, `--account-home` or a new token reference.
**Anthropic profile limitation.** An active or default profile in Anthropic's configuration directory can override a
stored Claude login. `CLAUDE_CONFIG_DIR` does not isolate that directory, and OpenRig does not change `ANTHROPIC_CONFIG_DIR`
or rewrite global profiles. Check or deselect the profile before using a stored-login account home, and verify the
running account privately.
An isolated Claude launch removes `ANTHROPIC_PROFILE`, `ANTHROPIC_FEDERATION_RULE_ID` and `ANTHROPIC_ORGANIZATION_ID`
from its inherited environment. It also refuses profile selectors in the selected home's or project's settings `env`
blocks. An explicit subscription token takes precedence over these selectors, including in the legacy
subscription-token switch.
OpenRig receives only the token's label and file path. The launch reads the token; if the required file is unusable, no agent
starts. Selecting an API home without `--token-file` clears an older seat token reference when the selection commits.
The home's API helper can stay in its user settings. For a selected plan token, OpenRig refuses a home whose API helper
or credential environment would take precedence over that token. Both modes refuse project credential overrides.
OpenRig leaves those settings unchanged.
The selected home persists across managed launches and restores. These launches exclude inherited Claude/OpenAI
credentials and routing variables so credentials from the daemon or shell cannot override the selected home. OpenRig
refuses to launch if it cannot read the saved selection or the shell cannot enforce isolation. Launches without a home
selection and existing subscription-login switches keep their behavior. A Claude seat using account-home mode must
use that mode for later account changes too.
User-managed provider settings can still affect authentication. After a real tool turn, check which account and billing
identity the runtime used. Do not print tokens or the process environment. The configured home alone cannot verify the
running account.
Before downgrading to a version without account-home switching, stop the managed seats. Select their accounts using
that version's supported setup before resuming. Older versions ignore the saved selection and may launch with inherited
or default credentials. Rolling back the binary alone does not preserve the selected billing account.
OpenRig leaves the imported team file unchanged. Exporting and reimporting a team does not preserve account-home
isolation, so configure it explicitly for each new rig. If a switch is interrupted, inspect it with `rig seat status`
and follow the printed recovery command. Wait until the outcome is known before starting another switch.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
- Spec values are stored in OpenRig's database and in rig snapshots, and `rig export` and bundles copy them, so a secret put there by mistake travels with the rig.
@@ -633,52 +463,6 @@ and follow the printed recovery command. Wait until the outcome is known before
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
### Switching credentials while keeping the conversation
`rig provider switch` resumes a running seat in its existing home and pane. A stopped Claude Code
seat isn't relaunched: the switch saves the new reference for its next launch, but only when the
launch it predicts resumes the seat's conversation. That prediction covers the snapshot that
launch would use and restore's automatic periodic carry. It then prints the launch that resumes:
`rig launch <rig> <seat> --snapshot-id <id>` for a saved conversation, or a plain
`rig launch <rig> <seat>` when the resume comes through the periodic carry, which naming a
snapshot would turn off. Otherwise it refuses and changes nothing. `rig provider precheck` gives
the same answer.
For Claude Code, select an API-key file, a subscription-token file, or the home's stored login:
```sh
rig provider precheck --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account plan --token-file /secure/claude-plan-token
rig provider switch --seat dev-a@my-rig --stored-login
```
The API-key file must be private (0600 or 0400), nonempty, and at most 8192 bytes, containing
one key with optional surrounding whitespace. Only the reference is stored. The child reads the
key at launch; key bytes never enter command arguments, snapshots or switch reports. Classic
launches require a supported POSIX pane shell. Missing/invalid API-key files prevent launch.
Settings that could override the key cause a refusal; OpenRig does not edit those settings.
Native managed settings and a signed-in Claude gateway cannot be ruled out; Claude may require API-key approval before its
first turn. Check `/status` after a real tool turn to confirm the authentication method.
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
before running seats there, or prepare/import a native auth profile separately. Switches preserve
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
policy can override `auth.json`; the report remains unverified until native account verification.
Select or clear an account home in a separate operation: `--api-key-file` cannot be combined with
`--account-home` or `--clear-account-home`. A credential switch inside an already selected home
keeps that home.
Both paths retain the conversation and launch settings, interrupt in-flight work, and report
uncertain restarts without retrying. They do not move account homes or start a fresh conversation.
The caller chooses subscription-first order and configures/enforces the API budget; this command
neither selects accounts automatically nor imposes a spending limit. Downgrade only after switching
API-key Claude seats back to subscription credentials: older OpenRig versions cannot read the new
credential kind correctly.
### Choosing the Claude instruction file
OpenRig writes its instructions for Claude Code members into managed blocks in
@@ -741,15 +525,12 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
@@ -763,53 +544,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `starter_ref` | object | no | — | `{ name }` of an agent starter (`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) whose files lead the seat's startup. Not valid on terminal members or with a fork. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Claude MCP loading (`runtime: claude-code`)
Set `claude_mcp: none` on an individual member to launch it without MCP servers:
```yaml
members:
- id: reviewer
agent_ref: local:agents/reviewer
profile: default
runtime: claude-code
cwd: .
claude_mcp: none
```
OpenRig passes `--strict-mcp-config --mcp-config '{"mcpServers":{}}'` to
Claude for this seat. Strict mode selects only the supplied empty server set,
excluding servers from user, project, team and plugin configuration. It does
not disable plugin skills or change the seat's permission mode. The choice is
saved on the seat and carried through fresh starts, forks, resumes (including
saved startup context) and handover successors. Export and bundle round trips
preserve it. The same field is supported on legacy `nodes` declarations.
Other agents in the same directory keep their own choices; OpenRig does not
rewrite shared MCP configuration files. Omit the field for Claude's normal
loading. A later launch from an authored spec without the field clears the
seat's saved opt-out once the launch is accepted for startup; a rejected
launch leaves the choice unchanged. Restarting from saved context keeps it.
Only `none` is accepted, and the field is invalid on other runtimes. If the
seat switches to another runtime, the Claude choice stays dormant and applies
again if it switches back. A currently running Claude process is unaffected.
Export describes the current runtime and omits a dormant Claude choice. YAML
exports name each affected agent in a `# Not carried:` comment, which `rig export`
also prints after writing the file; JSON exports keep their existing shape. If
you rebuild from that export and switch back to Claude, restore `claude_mcp: none`
in the Claude declaration. Re-check this setting after downgrading
OpenRig too: older versions can ignore it.
Shutdown resume probes and the displayed native recovery commands (including
the conversation picker) also carry the saved MCP choice. If shutdown cannot
read that choice, it skips the probe and retains the token; shutdown proceeds.
This opt-out is incompatible with an active enterprise `managed-mcp.json`:
[Claude rejects strict overrides of that managed server set](https://code.claude.com/docs/en/managed-mcp#servers-passed-with---mcp-config-or---strict-mcp-config).
OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -847,7 +581,7 @@ own defaults/settings apply. Other runtimes ignore this field.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
@@ -912,11 +646,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
cycle detection and visible fallback outcomes.
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
### Pod-Local Edges
@@ -999,22 +729,6 @@ the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
For member `startup:`, export uses that seat's retained launch recipe (or its pre-launch materialization) rather than guessing from projected files. If that source is unavailable or a member's files came from a different rig root, the YAML names the member's omitted startup; JSON export names omissions in the `X-OpenRig-Export-Warnings` response header. An older seat without a recipe may have had no member startup or an unretained one: inspect its source before re-importing. When member startup files are present in the export, `rig export` checks required paths against the output folder and warns for missing files. The export does not copy member startup files.
`rig export` includes the rig's saved culture file and startup block, and writes
saved pod startup files and actions under their pod. The file and action order,
delivery hints, required flags, orientation and `applies_on` selections are retained.
Pod actions are saved for export when a new rig is created; older rigs and pods
added from a separate expansion may not have those declarations recorded. Export
does not reconstruct them from a member's combined startup context; keep the source
spec when those declarations are needed. This does not change which startup actions
are inherited when a seat is added later.
File paths remain relative to the exported spec's folder. When exporting to a
different folder, supply the referenced startup files there or import with the
original `--rig-root`. The CLI warns once per missing required rig, pod or member
file; missing optional files do not trigger that warning.
### Files
| Field | Type | Required | Default | Description |
@@ -1023,7 +737,7 @@ file; missing optional files do not trigger that warning.
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation delivers none, apart from putting back a missing managed guidance block. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
#### Delivery Hints
@@ -1041,8 +755,8 @@ file; missing optional files do not trigger that warning.
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`, `startup_proof`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | Command/text to send, or `authenticated` / `none` for `startup_proof`. |
| `phase` | string | no | `after_files` | When to execute text/commands. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). Proof selection is resolved before projection regardless of phase. |
| `idempotent` | boolean | yes | — | Whether this action is safe to run again on a restore without the earlier conversation or a retried start. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation runs none (see the startup guide). |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
### Startup proof selection
-6
View File
@@ -86,9 +86,3 @@ UNKNOWN is `{ "status": "unknown", "reason": "..." }`; older daemons may yield
`null`. `--full` and `--fields modelObservation` retain the full object, including
its status and occupant generation. Human output and roster reports use the full
labelled observation.
A member marked `"hosted": true` is a hosted member: a tool outside the terminal that reaches the team over MCP, with no node or pane.
`rig_team` lists it with its capabilities and caveat, no pane and activity unknown, unless its seat is one of the rig's nodes.
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
@@ -1,75 +0,0 @@
# Runner conformance check
A runnable check for runner authors. Point it at your runner and it reports which promises of the
[harness and runner contract](../harness-runner-contract.md) your runner keeps. It needs Node and tmux, and no OpenRig
daemon, model, credentials or network.
```sh
node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-harness --resume --first-input
```
- **`{agent}`** is replaced with the path of the fake ACP agent beside this file. Your runner starts it where it would
start your real agent. It accepts any arguments your runner adds.
- **`--name`** is your preset's name, which your posts carry as `runtime`.
- **`--resume` and `--first-input`** say your preset declares `resume: true` and `first_input: true`. Without them,
those cases are skipped, because OpenRig wouldn't pass that input.
- **`--cases 2,4`** runs only some cases. `--json` prints a machine-readable report. `--keep` keeps each launch's
folder: the agent's message log, the launch record and the scenario.
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
## How it runs
- **Your runner** starts in a private tmux server, with the environment a runner-preset launch gives it:
`OPENRIG_URL`, `OPENRIG_ACTIVITY_HOOK_TOKEN`, `OPENRIG_SESSION_NAME`, `OPENRIG_NODE_ID`, `OPENRIG_RUNTIME`,
`OPENRIG_OCCUPANT_GENERATION`, `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT`, plus `OPENRIG_RESUME_REF` or
`OPENRIG_FIRST_INPUT` when a case needs them. `OPENRIG_HOME` is an empty folder, so nothing reaches a real OpenRig.
- **The fake agent** speaks ACP v1 over stdio. Each case gives it a scenario, such as a slow handshake, a permission
request, a turn that ends cancelled, or an exit mid-turn. It logs every message in both directions. Its own request
ids are numbers, as JSON-RPC agents commonly use, so a runner passes them on unchanged.
- **A local endpoint** stands in for `POST /api/activity/hooks`. It records each post, and refuses one without the
launch's bearer token, as OpenRig does.
- **In cases 4 and 4b,** it also stands in for the answered path's three routes: submit, read with `waitSeconds`, and
withdraw. It checks a submit the way OpenRig does: the seat and generation, `nativeRef` as a non-empty string, `turnId`
and `requestId` as a string or a number (a number reads as its decimal string). Options a person can't be shown (not
2-4 of them, ids that repeat or use other characters, or other kinds) are permitted with the agent's allow option, as OpenRig does. Each launch scripts the answer,
the permit or the refusal it gives, or closes the connection unanswered.
- **Where a launch scripts none of that,** those routes answer 404, as an OpenRig without the answered path does, so
the runner permits.
- **Input arrives the way `rig send` delivers it:** the text is pasted as one bracketed paste with raw newlines, then
Enter is pressed. Ordinary messages carry a From/To envelope.
## The cases
| Case | Contract section | What it checks |
|---|---|---|
| 1 | 3 | No launch record until `session/new` is answered. Then a record holding format 1, this attempt, the preset's name, the agent's conversation id and a `producer` |
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
| 9 | 9 | `OPENRIG_FIRST_INPUT`'s exact bytes, newlines and spaces included, are the first prompt |
## What it doesn't check
- **How OpenRig reads your posts:** working, idle, needs input, unknown after an exit, a send refused while a
permission waits, and an earlier generation not counting. That's OpenRig's side of the contract, and its own tests
cover it.
- **Your real agent:** a passing check isn't a run with your agent. Section 10's real check, with your agent under a
runner preset, is still the useful evidence. Passing this is evidence for the versions you ran, not certification.
- **The model handoff:** `OPENRIG_MODEL` is mapped to your harness's own setting, with no wire this check can see.
- **A resume for a preset without `resume`:** OpenRig doesn't pass `OPENRIG_RESUME_REF` to it.
- **Restarting the agent after a failed handshake:** the contract doesn't rule on that.
- **The chosen person's side of an ask:** who may answer, the Feed and Slack are OpenRig's side, and its own tests
cover them. Routing an ask to a seat is a later change.
@@ -1,721 +0,0 @@
#!/usr/bin/env node
// OpenRig runner conformance check: point it at your runner and see which promises of the runner contract
// (docs/reference/harness-runner-contract.md) it keeps. It needs Node and tmux, and no OpenRig daemon:
// - your runner starts in a private tmux server, with the environment a runner-preset launch gives it;
// - the fake ACP agent beside this file stands in for your agent (your runner starts it where it would start the real one);
// - a small local endpoint stands in for OpenRig's activity route, and records what your runner posts;
// - input arrives the way `rig send` delivers it: one bracketed paste with raw newlines, then Enter.
//
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input]
// [--cases 1,2,4] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]
//
// `{agent}` in the command is replaced with the fake agent's path, an executable that takes any arguments your runner
// adds. For example: --runner 'my-runner --agent-command {agent}'. --resume and --first-input say your preset declares
// `resume: true` and `first_input: true`; without them those cases are skipped, since OpenRig wouldn't pass that input.
// The runner gets a clean environment: PATH, HOME, USER, SHELL, LANG and TMPDIR, the OpenRig variables, and any
// --pass-env names. Exit status: 0 when nothing fails, 1 when a case fails, 2 for a usage or setup problem.
//
// It checks the runner's side only. How OpenRig reads those posts (working, idle, needs input) is OpenRig's own tests'
// job, and one real run with your agent is still the useful check (section 10 of the contract). Passing this is
// evidence for the versions you ran, not certification.
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
function parseArgs(argv) {
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, json: false, keep: false, waitMs: 8000 };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = () => { if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`); return argv[++i]; };
if (flag === "--runner") opts.runner = value();
else if (flag === "--name") opts.name = value();
else if (flag === "--cases") opts.cases = value().split(",").map((c) => c.trim()).filter(Boolean);
else if (flag === "--pass-env") opts.passEnv.push(value());
else if (flag === "--wait-ms") opts.waitMs = Number(value());
else if (flag === "--resume") opts.resume = true;
else if (flag === "--first-input") opts.firstInput = true;
else if (flag === "--json") opts.json = true;
else if (flag === "--keep") opts.keep = true;
else throw new Error(`unknown option ${flag}`);
}
if (!opts.runner) throw new Error("--runner is required");
if (!opts.runner.includes("{agent}")) throw new Error("--runner must contain {agent}, where your runner takes its agent command");
if (!/^[A-Za-z0-9._-]+$/.test(opts.name)) throw new Error("--name must be a preset name (letters, digits, '.', '_' or '-')");
if (!(opts.waitMs > 0)) throw new Error("--wait-ms must be a positive number");
return opts;
}
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (error) { console.error(`${error.message}\n${usage}`); process.exit(2); }
if (spawnSync("tmux", ["-V"]).status !== 0) { console.error("tmux is required: the check drives your runner in a tmux pane, as OpenRig does."); process.exit(2); }
const socket = `openrig-runner-check-${process.pid}`;
const tmux = (...args) => spawnSync("tmux", ["-L", socket, ...args], { encoding: "utf8" });
const sh = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Settling times and per-step bounds grow with --wait-ms on a slow machine; they never shrink below these values. The
// fake agent's own delays and holds grow by the same factor, so each settle keeps its place against what it measures.
const scale = (ms) => Math.round(ms * Math.max(1, opts.waitMs / 8000));
async function waitFor(read, ms = opts.waitMs) {
const deadline = Date.now() + ms;
for (;;) {
const value = read();
if (value) return value;
if (Date.now() > deadline) return undefined;
await sleep(50);
}
}
// The stand-in for OpenRig's activity route and, for a launch that scripts one, its answered permission routes (section 7).
// Each launch has its own token; a request without one is refused, as OpenRig does.
const posts = [];
const tokens = new Map();
const OPTION_ID = /^[A-Za-z0-9_-]{1,40}$/;
const ANSWERABLE = new Set(["allow_once", "allow_always", "reject_once", "reject_always"]);
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
// A JSON-RPC id or prompt number as the runner passed it: a number reads as its decimal string, so 7 and "7" match.
const idText = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : text(value);
const server = createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => { raw += chunk; });
req.on("end", () => {
const token = (req.headers.authorization ?? "").replace(/^Bearer /, "");
let body;
try { body = raw ? JSON.parse(raw) : undefined; } catch { body = undefined; }
const url = new URL(req.url, "http://stand-in");
posts.push({ at: Date.now(), method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), token, body, raw });
const send = (status, payload) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(payload));
const h = tokens.get(token);
if (req.method === "POST" && url.pathname === "/api/activity/hooks") {
if (!h) return send(401, { ok: false });
return body ? send(200, { ok: true }) : send(400, { ok: false, code: "invalid_json" });
}
const ask = url.pathname.match(/^\/api\/activity\/permission-requests(?:\/([^/]+)(\/withdraw)?)?$/);
if (!ask) return send(404, { ok: false, code: "not_found" });
if (!h) return send(401, { ok: false });
// Without a scripted ask, the stand-in is an OpenRig that predates the answered path: 404, so the runner permits.
if (!h.asks) return send(404, { ok: false, code: "not_found" });
// A scripted `drop` closes the connection unanswered: an OpenRig the runner can't reach.
const drop = req.method === "POST" && !ask[1] ? h.asks.script.submit?.drop : req.method === "GET" && h.asks.script.read?.drop;
if (drop) return void res.socket.destroy();
if (req.method === "POST" && !ask[1]) return submitAsk(h, body, raw, send);
const entry = h.asks.byId.get(decodeURIComponent(ask[1] ?? ""));
if (!entry) return send(404, { ok: false, code: "not_found" });
if (req.method === "GET" && h.asks.script.read) return send(h.asks.script.read.status, { ok: false, code: h.asks.script.read.code });
if (req.method === "POST" && ask[2]) {
if (!["turn_cancelled", "turn_ended", "exited"].includes(body?.reason)) return send(400, { ok: false, code: "invalid_permission_request" });
entry.withdrawn = body.reason;
entry.wake();
return send(200, { ok: true, ...askState(h, entry) });
}
if (req.method === "GET" && !ask[2]) {
const waitMs = Math.min(Number(url.searchParams.get("waitSeconds") ?? 0), 25) * 1000;
const reply = () => send(200, { ok: true, ...askState(h, entry) });
if (askState(h, entry).state !== "pending" || !(waitMs > 0)) return reply();
const timer = setTimeout(() => { entry.waiters.delete(reply); reply(); }, waitMs);
entry.waiters.add(() => { clearTimeout(timer); reply(); });
return undefined;
}
return send(404, { ok: false, code: "not_found" });
});
});
// What the scripted ask reads as now: an answer once its time has come, `withdrawn` after a withdraw, otherwise pending. A
// `late` answer is what the stand-in reports after a withdraw, so a runner that keeps reading and delivers it is caught.
function askState(h, entry) {
const script = h.asks.script;
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
const { state, optionId, reason } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
}
return { state: "pending" };
}
// Submit, checked as OpenRig checks it: the seat and generation, nativeRef as a string, turnId and requestId as a string
// or a number. When no one can be asked, OpenRig permits with the agent's allow_once, else allow_always: here when the
// script says so, or for options a person can't be shown (2-4 with distinct ids). With no allow option it refuses.
function submitAsk(h, body, raw, send) {
const script = h.asks.script;
const problems = [];
if (!body) return send(400, { ok: false, code: "invalid_json" });
if (body.sessionName !== h.session) return send(404, { ok: false, code: "session_not_found" });
if (!text(body.nativeRef)) problems.push(`nativeRef must be a non-empty string (got ${JSON.stringify(body.nativeRef)})`);
for (const key of ["turnId", "requestId"]) if (!idText(body[key])) problems.push(`${key} must be a non-empty string or a number (got ${JSON.stringify(body[key])})`);
const options = Array.isArray(body.options) ? body.options : null;
if (!options) problems.push("options must be an array");
else if (options.some((o) => !text(o?.optionId) || !text(o?.name) || !text(o?.kind))) problems.push("each option needs optionId, name and kind");
h.asks.submitted.push({ body, problems });
if (problems.length) return send(400, { ok: false, code: "invalid_permission_request", error: problems.join("; ") });
if (body.generation !== h.generation) return send(409, { ok: false, code: "stale_generation" });
if (script.submit?.status) return send(script.submit.status, { ok: false, code: script.submit.code });
const allow = options.find((o) => o.kind === "allow_once") ?? options.find((o) => o.kind === "allow_always");
const permit = (reason) => allow ? send(200, { ok: true, state: "permitted", optionId: allow.optionId, reason })
: send(422, { ok: false, code: "options_unsupported" });
if (script.submit?.permitted) return permit("no one can be asked");
if (options.length < 2 || options.length > 4 || new Set(options.map((o) => o.optionId)).size !== options.length
|| options.some((o) => !OPTION_ID.test(o.optionId) || !ANSWERABLE.has(o.kind))) return permit("these options can't be shown to a person");
const requestId = idText(body.requestId), turnId = idText(body.turnId);
const key = JSON.stringify([body.generation, text(body.nativeRef), requestId]);
const earlier = h.asks.byKey.get(key);
if (earlier) return send(200, { ok: true, askId: earlier.askId, created: false });
if ([...h.asks.byKey.values()].some((e) => e.requestId === requestId && e.turnId !== turnId)) return send(409, { ok: false, code: "duplicate_request" });
const entry = { askId: `ask-${h.label}-${h.asks.byKey.size + 1}`, requestId, turnId, created: Date.now(), waiters: new Set() };
entry.wake = () => { for (const w of [...entry.waiters]) w(); entry.waiters.clear(); };
if (script.answer) setTimeout(entry.wake, script.answer.afterMs ?? 0);
h.asks.byKey.set(key, entry);
h.asks.byId.set(entry.askId, entry);
return send(200, { ok: true, askId: entry.askId, created: true });
}
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const launches = [];
async function launch(label, { scenario = {}, env = {}, asks } = {}) {
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
const cwd = join(dir, "work");
mkdirSync(cwd);
mkdirSync(join(dir, "home"));
mkdirSync(join(dir, "launch"));
const id = randomBytes(4).toString("hex");
const h = {
label, dir, cwd, token: randomBytes(16).toString("hex"), session: `check-${label}@runner-check`, nodeId: `node-${label}-${id}`,
generation: `gen-${id}`, attempt: `attempt-${id}`, recordPath: join(dir, "launch", "record.json"), agentLog: join(dir, "agent.jsonl"),
target: `check-${label}`,
};
if (asks) h.asks = { script: asks, byKey: new Map(), byId: new Map(), submitted: [] };
tokens.set(h.token, h);
writeFileSync(join(dir, "scenario.json"), JSON.stringify(scenario));
const agent = join(dir, "agent");
writeFileSync(agent, `#!/bin/sh\nexec ${sh(process.execPath)} ${sh(join(here, "fake-acp-agent.mjs"))} --scenario ${sh(join(dir, "scenario.json"))} --log ${sh(h.agentLog)} "$@"\n`);
chmodSync(agent, 0o755);
const vars = {
PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, LANG: process.env.LANG,
TMPDIR: process.env.TMPDIR, TERM: "xterm-256color",
...Object.fromEntries(opts.passEnv.map((name) => [name, process.env[name]])),
OPENRIG_HOME: join(dir, "home"), OPENRIG_URL: base, OPENRIG_ACTIVITY_HOOK_TOKEN: h.token, OPENRIG_SESSION_NAME: h.session,
OPENRIG_NODE_ID: h.nodeId, OPENRIG_RUNTIME: opts.name, OPENRIG_OCCUPANT_GENERATION: h.generation,
OPENRIG_LAUNCH_RESULT: h.recordPath, OPENRIG_LAUNCH_ATTEMPT: h.attempt, ...env,
};
const assignments = Object.entries(vars).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${sh(value)}`).join(" ");
const command = opts.runner.replaceAll("{agent}", sh(agent));
writeFileSync(join(dir, "launch.sh"), `#!/bin/sh\ncd ${sh(cwd)} || exit 1\nexec /usr/bin/env -i ${assignments} /bin/sh -c ${sh(command)}\n`);
chmodSync(join(dir, "launch.sh"), 0o755);
const started = tmux("new-session", "-d", "-s", h.target, "-x", "200", "-y", "50", join(dir, "launch.sh"));
if (started.status !== 0) throw new Error(`tmux couldn't start the runner: ${started.stderr.trim()}`);
Object.assign(h, {
log: () => !existsSync(h.agentLog) ? [] : readFileSync(h.agentLog, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)),
received: (method) => h.log().filter((e) => e.dir === "in" && e.msg.method === method),
sent: (method) => h.log().filter((e) => e.dir === "out" && e.msg.method === method),
answerTo: (method) => { const ask = h.sent(method)[0]; return ask && h.log().find((e) => e.dir === "in" && e.msg.id === ask.msg.id && !e.msg.method); },
replyTo: (entry) => h.log().find((e) => e.dir === "out" && e.msg.id === entry.msg.id && !e.msg.method),
events: (event) => h.log().filter((e) => e.dir === "event" && e.event === event),
prompts: () => h.received("session/prompt").map((e) => (e.msg.params?.prompt ?? []).filter((b) => b.type === "text").map((b) => b.text).join("")),
posts: () => posts.filter((p) => p.path === "/api/activity/hooks" && (p.token === h.token || p.body?.sessionName === h.session)),
permissionCalls: (method, suffix = "") => posts.filter((p) => p.token === h.token && p.method === method
&& p.path.startsWith("/api/activity/permission-requests") && p.path.endsWith(suffix)),
hooks: (event, subtype) => h.posts().filter((p) => p.body?.hookEvent === event && (subtype === undefined || p.body?.subtype === subtype)),
record: () => { if (!existsSync(h.recordPath)) return undefined; try { return JSON.parse(readFileSync(h.recordPath, "utf8")); } catch { return { unreadable: true }; } },
paste: (text) => {
const file = join(dir, `paste-${randomBytes(3).toString("hex")}`);
writeFileSync(file, text);
const buffer = `b-${randomBytes(3).toString("hex")}`;
tmux("load-buffer", "-b", buffer, file);
tmux("paste-buffer", "-t", h.target, "-b", buffer, "-d", "-r", "-p");
tmux("send-keys", "-t", h.target, "Enter");
},
keys: (...keys) => tmux("send-keys", "-t", h.target, ...keys),
stop: () => tmux("kill-session", "-t", h.target),
screen: () => tmux("capture-pane", "-p", "-t", h.target).stdout ?? "",
});
launches.push(h);
return h;
}
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
function result(id, title, { proposed = false } = {}) {
const r = { id, title, status: "pass", checks: [], ...(proposed ? { proposed: true } : {}) };
r.check = (name, ok, detail) => { r.checks.push({ name, ok: Boolean(ok), ...(ok ? {} : { detail }) }); if (!ok) r.status = "fail"; return ok; };
r.skip = (reason) => { r.status = "skip"; r.reason = reason; return r; };
return r;
}
const cases = {
async "1"() {
const r = result("1", "Handshake: the launch record comes only after the conversation is open");
const h = await launch("1", { scenario: { newDelayMs: scale(2000), sessionId: "fake-session-1" } });
const asked = await waitFor(() => h.received("session/new")[0]);
if (!r.check("the runner opens a conversation with session/new", asked, "no session/new arrived")) return r;
// Until the agent's answer is logged, there must be no record. The agent logs each message before it sends it, so a
// record read before a log that still holds no answer was written before the answer went out. Reading against the
// agent's log, not after a fixed settle, keeps a busy machine from reading a correct runner's record as early.
let early;
await waitFor(() => {
const record = h.record();
if (h.replyTo(asked)) return true;
if (record) early = record;
return early;
}, scale(2000) + opts.waitMs);
r.check("no launch record while session/new is still unanswered", !early, `a record was written early: ${JSON.stringify(early)}`);
const record = await ready(h);
r.check("a launch record arrives after session/new is answered", record, `no record with a nativeRef at ${h.recordPath}`);
if (!record) return r;
const answered = h.replyTo(asked);
r.check("the record is written after the answer", answered && statSync(h.recordPath).mtimeMs >= answered.at - 5, "the record's time precedes the answer");
r.check("format 1, this attempt, this preset", record.format === 1 && record.attempt === h.attempt && record.harness === opts.name,
`format=${record.format} attempt=${record.attempt} harness=${record.harness}`);
r.check("nativeRef is the agent's conversation id", record.nativeRef === "fake-session-1", `nativeRef=${record.nativeRef}`);
r.check("producer names the runner's versions", typeof record.producer === "string" && record.producer.trim(), "no producer");
return r;
},
async "1b"() {
const r = result("1b", "A failed handshake writes no launch record and claims no turn");
for (const [label, scenario] of [["initialize error", { initialize: "error" }], ["initialize exit", { initialize: "exit" }],
["session/new error", { newSession: "error" }], ["session/new exit", { newSession: "exit" }]]) {
const h = await launch(`1b-${label.replace(/[^a-z]+/g, "-")}`, { scenario });
await waitFor(() => h.events("exit")[0] || h.log().some((e) => e.dir === "out" && e.msg.error), opts.waitMs);
await sleep(scale(1500));
const record = h.record();
r.check(`${label}: no conversation in a launch record`, !record?.nativeRef, `record: ${JSON.stringify(record)}`);
r.check(`${label}: no accepted prompt or idle report`, h.hooks("UserPromptSubmit").length === 0 && nonExitStops(h).length === 0,
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
}
return r;
},
async "2"() {
const r = result("2", "Input framing, queued messages and cancel");
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(2500), ignoreCancel: true }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const block = message(h, "first line\n/abort\nthird line");
h.paste(block);
await waitFor(() => h.prompts().length >= 1);
await waitFor(() => nonExitStops(h).length >= 1, scale(3000));
await sleep(scale(500));
r.check("one pasted block is exactly one session/prompt", h.prompts().length === 1, `got ${h.prompts().length} prompts`);
r.check("the prompt holds the whole block, byte for byte", h.prompts()[0] === block, `got ${JSON.stringify(h.prompts()[0])}`);
h.paste(message(h, "please hold"));
if (!r.check("the next block starts the next turn", await waitFor(() => h.prompts().length >= 2), "no second prompt")) return r;
// A block that arrives while the agent works, with a line reading /abort, is still one message for later.
const queued = message(h, "sent while the agent works\n/abort\nstill part of the message");
h.paste(queued);
await sleep(scale(1200));
r.check("a block sent mid-turn waits for the next turn", h.prompts().length === 2, `got ${h.prompts().length} prompts during the turn`);
r.check("a line reading /abort inside a block isn't control", h.received("session/cancel").length === 0, "the runner sent session/cancel");
const before = Date.now();
h.paste("/abort");
const cancel = await waitFor(() => h.received("session/cancel")[0], scale(3000));
r.check("a block that is exactly /abort sends session/cancel", cancel, "no session/cancel");
const stopped = await waitFor(() => nonExitStops(h, before)[0], scale(3000));
r.check("the cancelled turn reports Stop/cancelled", stopped?.body?.subtype === "cancelled", `got ${stopped ? `Stop/${stopped.body.subtype}` : "no Stop"}`);
await sleep(scale(300));
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
const interrupted = Date.now();
await sleep(scale(200));
h.keys("C-c");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2, scale(3000)), "no second session/cancel");
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
return r;
},
async "3"() {
const r = result("3", "Activity: turn boundaries, posted in order with the seat's identity");
const h = await launch("3");
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a short turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
r.check("UserPromptSubmit/accepted when the prompt is given", accepted, `posts: ${h.posts().map((p) => p.body?.hookEvent).join(", ")}`);
r.check("the accepted post names a turnId", accepted && accepted.body.turnId !== undefined && accepted.body.turnId !== "", "no turnId");
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0]);
r.check("Stop/end_turn when the turn ends", end, "no Stop/end_turn");
r.check("accepted comes before the turn's end", accepted && end && accepted.at <= end.at, "out of order");
const all = h.posts();
const missing = all.flatMap((p) => ["sessionName", "nodeId", "runtime", "generation", "sequence", "occurredAt", "hookEvent", "subtype"]
.filter((k) => p.body?.[k] === undefined).map((k) => `${p.body?.hookEvent ?? "?"} lacks ${k}`));
r.check("every post carries the contract's fields", missing.length === 0, missing.join("; "));
r.check("every post uses the hook token", all.every((p) => p.token === h.token), "a post without the bearer token");
r.check("every post names this seat and preset",
all.every((p) => p.body?.sessionName === h.session && p.body?.nodeId === h.nodeId && p.body?.runtime === opts.name),
all.map((p) => `${p.body?.sessionName}/${p.body?.nodeId}/${p.body?.runtime}`).join(", "));
const seqs = all.map((p) => Number(p.body?.sequence));
r.check("sequence rises with each post", seqs.every((s, i) => Number.isFinite(s) && (i === 0 || s > seqs[i - 1])), `sequences: ${seqs.join(", ")}`);
const beats = all.filter((p) => p.body?.livenessSeconds !== undefined);
r.check("livenessSeconds, when given, is a whole number from 1 to 3600",
beats.every((p) => Number.isInteger(p.body.livenessSeconds) && p.body.livenessSeconds >= 1 && p.body.livenessSeconds <= 3600),
beats.map((p) => p.body.livenessSeconds).join(", "));
return r;
},
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
async "4"() {
const r = result("4", "Permissions: permitted when no one can be asked; refused and held only on denied_by_seat or no allow option");
const option = (optionId, kind) => ({ optionId, name: optionId, kind });
const withAlways = [option("allow-once", "allow_once"), option("allow-always", "allow_always"), option("reject-once", "reject_once")];
const rejectOnly = [option("reject-once", "reject_once"), option("reject-always", "reject_always")];
const cells = [
// label, the stand-in's answered path (none: an OpenRig without it, which answers 404), the options, permitted, and
// whether /abort cancels the turn after the refusal
["404", undefined, withAlways, true],
["unreachable", { submit: { drop: true } }, withAlways, true],
["denied_by_seat", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false],
["denied_by_seat, then /abort", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false, true],
["no allow option", undefined, rejectOnly, false],
];
await Promise.all(cells.map(async ([label, asks, options, permitted, abort]) => {
const turns = [{ permission: { options }, holdMs: scale(abort ? 4000 : 1500) }, {}];
const h = await launch(`4-${label.replace(/[^a-z0-9]+/g, "-")}`, { scenario: { turns }, asks });
if (!r.check(`${label}: the runner opens its conversation`, await ready(h), "no launch record")) return;
h.paste(message(h, "edit the notes"));
const asked = await waitFor(() => h.sent("session/request_permission")[0]);
if (!r.check(`${label}: the agent's permission request is sent`, asked, "the turn never reached the request")) return;
// A runner may take a moment to find OpenRig unreachable; everything else is answered at once.
const answer = await waitFor(() => h.answerTo("session/request_permission"), scale(label === "unreachable" ? 10000 : 3000));
if (!r.check(`${label}: the request is answered, not left pending`, answer, "no answer")) return;
if (label !== "unreachable") r.check(`${label}: it's answered at once (within 2 s)`, answer.at - asked.at <= 2000, `${answer.at - asked.at} ms`);
const got = answer.msg.result?.outcome;
if (permitted) {
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === "allow-once",
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
r.check(`${label}: the turn's end is reported as usual`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(4000)),
"no Stop/end_turn after the permit");
r.check(`${label}: nothing is held as needs input`, h.hooks("Notification", "permission_prompt").length === 0, "the runner posted permission_prompt");
return;
}
r.check(`${label}: the answer is a refusal, never an allow option`, got?.outcome === "cancelled",
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
const shown = await waitFor(() => h.hooks("Notification", "permission_prompt")[0], scale(3000));
r.check(`${label}: Notification/permission_prompt is posted`, shown, "no permission_prompt post");
if (label === "denied_by_seat") r.check(`${label}: what was asked is shown in the pane`, await waitFor(() => h.screen().includes("Edit notes.txt"), scale(3000)),
"the pane never showed the tool call's title, Edit notes.txt");
// A cancelled turn is still a turn's end: the refusal stays held until the next accepted prompt.
if (abort) {
h.paste("/abort");
r.check(`${label}: /abort sends session/cancel`, await waitFor(() => h.received("session/cancel")[0], scale(3000)), "no session/cancel");
}
await waitFor(() => h.log().some((e) => e.dir === "out" && e.msg.result?.stopReason), scale(abort ? 6000 : 4000));
await sleep(scale(800));
r.check(`${label}: no idle Stop or beat while holding that attention`, shown && nonExitStops(h, shown.at).length === 0,
`posted ${nonExitStops(h, shown?.at ?? 0).map((p) => `Stop/${p.body.subtype}`).join(", ")}`);
const next = Date.now();
h.paste(message(h, "carry on"));
r.check(`${label}: the next prompt is accepted as usual, which clears it`,
await waitFor(() => h.hooks("UserPromptSubmit", "accepted").some((p) => p.at >= next)), "no accepted post for the next prompt");
}));
return r;
},
// Section 7's answered path: the runner submits the live request, delivers the option that comes back (the person's
// answer, or OpenRig's permit) once while that request is live, and withdraws it when the turn or the agent ends.
async "4b"() {
const r = result("4b", "Answered permissions: the person's answer or OpenRig's permit reaches the live request, and nothing else does");
const options = ["allow-once", "reject-once"];
const submits = (h) => h.permissionCalls("POST").filter((p) => p.path === "/api/activity/permission-requests");
const withdraws = (h) => h.permissionCalls("POST", "/withdraw");
const answers = (h) => { const asked = h.sent("session/request_permission")[0];
return asked ? h.log().filter((e) => e.dir === "in" && e.msg.id === asked.msg.id && !e.msg.method) : []; };
const outcome = (h) => answers(h)[0]?.msg.result?.outcome;
const open = async (label, asks, turns = [{ permission: true }, {}]) => {
const h = await launch(`4b-${label}`, { scenario: { turns }, asks });
if (!await ready(h)) { r.check(`${label}: the runner opens its conversation`, false, "no launch record"); return undefined; }
h.paste(message(h, "edit the notes"));
await waitFor(() => h.sent("session/request_permission")[0]);
return h;
};
// An answer from the chosen person: the offered option, once, and the turn then reports as usual.
for (const [label, optionId] of [["allow", "allow-once"], ["deny", "reject-once"]]) {
const h = await open(label, { answer: { afterMs: 400, state: "answered", optionId } });
if (!h) continue;
const submitted = await waitFor(() => submits(h)[0], scale(3000));
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
const asked = h.sent("session/request_permission")[0];
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
const sent = submitted.body ?? {};
if (label === "allow") {
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
r.check("it uses the hook token", submitted.token === h.token, "no bearer token");
r.check("requestId is the agent's JSON-RPC id", idText(sent.requestId) === String(asked?.msg.id), `requestId=${JSON.stringify(sent.requestId)}`);
r.check("turnId is the accepted prompt's", accepted && idText(sent.turnId) === String(accepted.body.turnId), `turnId=${JSON.stringify(sent.turnId)}`);
r.check("nativeRef and generation name this conversation and occupant", sent.nativeRef === h.record()?.nativeRef && sent.generation === h.generation,
`nativeRef=${sent.nativeRef} generation=${sent.generation}`);
r.check("the agent's options are passed on as offered", JSON.stringify((sent.options ?? []).map((o) => o.optionId)) === JSON.stringify(options),
JSON.stringify(sent.options));
}
const delivered = await waitFor(() => outcome(h), scale(4000));
r.check(`${label}: the agent gets the chosen option`, delivered?.outcome === "selected" && delivered.optionId === optionId, `got ${JSON.stringify(delivered)}`);
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
await sleep(scale(300));
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
if (label === "allow") {
r.check("OpenRig records the prompt; the runner doesn't post permission_prompt itself", h.hooks("Notification", "permission_prompt").length === 0,
"the runner posted permission_prompt for a created ask");
const reads = h.permissionCalls("GET");
// Section 7 bounds the wait at 25 s and no lower; OpenRig's read takes 0 to 25 or none.
r.check("each read waits at most 25 s", reads.length > 0 && reads.every((p) => p.query.waitSeconds === undefined || Number(p.query.waitSeconds) <= 25),
reads.map((p) => p.query.waitSeconds ?? "(none)").join(", "));
r.check("after the answer, the turn's end is reported as usual", h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after delivery");
}
}
// OpenRig's permit, at submit or at a read, is delivered like an answer. So is the runner's own permit when OpenRig
// can't give an answer: a refusal section 7's table marks "permits" (two of them 409s, like stale_generation, so the
// code decides, never the status; a revoked grant can still be the live occupant's, since one is reissued after a lost
// response), a read that gets no answer, or a status, code or read state the section doesn't list. A refusal saying
// the request isn't the current occupant's, and a refused, stale or withdrawn read, can't take an answer: the agent
// gets cancelled, and nothing is held.
const outcomes = [
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
["unreachable read", { read: { drop: true } }, "allow-once"],
["unlisted read status (500)", { read: { status: 500, code: "internal_error" } }, "allow-once"],
["unlisted read state (deferred)", { answer: { afterMs: 300, state: "deferred" } }, "allow-once"],
["stale_generation", { submit: { status: 409, code: "stale_generation" } }, null],
["activity_grant_stale read", { read: { status: 409, code: "activity_grant_stale" } }, null],
["refused read (said_no)", { answer: { afterMs: 300, state: "refused", reason: "said_no" } }, null],
["stale ask", { answer: { afterMs: 300, state: "stale" } }, null],
["withdrawn ask", { answer: { afterMs: 300, state: "withdrawn" } }, null],
];
await Promise.all(outcomes.map(async ([label, asks, optionId]) => {
const h = await open(label.replace(/[^a-z0-9]+/g, "-"), asks, [{ permission: true, holdMs: scale(800) }, {}]);
if (!h) return;
// Section 7 sets no time limit for what follows a read, so the check waits its ordinary per-step bound (--wait-ms),
// and longer when the read got no answer, which a runner may retry a few times before it permits.
const got = await waitFor(() => outcome(h), label === "unreachable read" ? scale(10000) : opts.waitMs);
if (!optionId) {
r.check(`${label}: the agent gets cancelled`, got?.outcome === "cancelled", `got ${JSON.stringify(got)}`);
r.check(`${label}: nothing is held`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000))
&& !h.hooks("Notification", "permission_prompt").length, "no Stop/end_turn after the cancel, or the runner posted permission_prompt");
return;
}
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === optionId, `got ${JSON.stringify(got)}`);
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
await sleep(scale(300));
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
r.check(`${label}: the turn's end is reported as usual`, h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after the permit");
if (label === "permitted at submit") r.check(`${label}: there's no ask to read`, h.permissionCalls("GET").length === 0,
`${h.permissionCalls("GET").length} reads`);
// A read that got no answer leaves OpenRig's ask open, so it must not stay in the person's Feed after the turn.
if (label === "unreachable read" || label.startsWith("unlisted read")) r.check(`${label}: the ask is still withdrawn at the turn's end`,
await waitFor(() => withdraws(h).some((w) => w.body?.reason === "turn_ended"), scale(3000)), `withdraws: ${JSON.stringify(withdraws(h).map((w) => w.body))}`);
}));
// No answer: the request stays pending and visible, a pasted message isn't an answer, and /abort withdraws it, after
// which a late answer isn't delivered.
{
const h = await open("pending", { late: { state: "answered", optionId: "allow-once" } });
if (h) {
await sleep(scale(1500));
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
h.paste(message(h, "allow it"));
await sleep(scale(800));
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
const abortedAt = Date.now();
h.paste("/abort");
const withdrawn = await waitFor(() => withdraws(h)[0], scale(3000));
r.check("pending: /abort withdraws the ask with turn_cancelled", withdrawn?.body?.reason === "turn_cancelled", `withdraw: ${JSON.stringify(withdrawn?.body)}`);
r.check("pending: the agent's request is answered cancelled", (await waitFor(() => outcome(h), scale(3000)))?.outcome === "cancelled",
`got ${JSON.stringify(outcome(h))}`);
// Once the ask is withdrawn no person is still waiting, so the cancelled turn's own end is reported as usual (the
// pasted message above runs as the next turn, so only that turn's Stop/cancelled counts). The contract doesn't
// order the Stop and the withdraw, so the Stop is any after /abort, not only one after the withdraw arrived.
r.check("pending: after /abort, the turn's end is reported, holding nothing",
await waitFor(() => h.hooks("Stop", "cancelled").some((p) => p.at >= abortedAt), scale(3000)), "no Stop/cancelled after /abort");
await sleep(scale(1200));
r.check("pending: an answer after the withdraw isn't delivered", answers(h).every((a) => a.msg.result?.outcome?.outcome !== "selected"),
`answers: ${JSON.stringify(answers(h).map((a) => a.msg.result))}`);
}
}
// The turn ending, or the agent exiting, while the ask waits also withdraws it.
for (const [label, permission, reason] of [["turn end", { waitMs: scale(1500) }, "turn_ended"], ["agent exit", { exitAfterMs: scale(1500) }, "exited"]]) {
const h = await open(label.replace(/[^a-z]+/g, "-"), {}, [{ permission }, {}]);
if (!h) continue;
const withdrawn = await waitFor(() => withdraws(h)[0], scale(4000));
r.check(`${label}: the ask is withdrawn with ${reason}`, withdrawn?.body?.reason === reason, `withdraw: ${JSON.stringify(withdrawn?.body)}`);
// The turn's own end: a Stop with the agent's own stop reason, after the agent answered that turn's session/prompt,
// on either side of the withdraw (the contract doesn't order them). A Stop posted before the turn ended doesn't count.
if (reason === "turn_ended") {
const turnEnd = await waitFor(() => {
const asked = h.sent("session/request_permission")[0];
const prompt = asked && h.received("session/prompt").filter((p) => p.at <= asked.at).at(-1);
return prompt && h.replyTo(prompt);
}, scale(4000));
const stopReason = turnEnd?.msg.result?.stopReason;
r.check(`${label}: nothing is held once the ask is withdrawn`,
stopReason && await waitFor(() => h.hooks("Stop", stopReason).some((p) => p.at >= turnEnd.at), scale(3000)),
`no Stop/${stopReason ?? "(no turn end)"} after the agent ended the turn`);
}
}
return r;
},
async "5"() {
const r = result("5", "Generation: every post carries the launch's occupant generation");
const h = await launch("5");
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a short turn"));
await waitFor(() => h.hooks("Stop", "end_turn")[0]);
const all = h.posts();
r.check("the runner posted something", all.length > 0, "no posts");
r.check("generation is OPENRIG_OCCUPANT_GENERATION on every post", all.every((p) => p.body?.generation === h.generation),
all.map((p) => p.body?.generation).join(", "));
return r;
},
async "6"() {
const r = result("6", "Resume: the same conversation, or an honest missing or unconfirmed");
if (!opts.resume) return r.skip("the preset doesn't declare resume (--resume): OpenRig passes OPENRIG_RESUME_REF only to one that does");
const reopenCalls = (h) => [...h.received("session/load"), ...h.received("session/resume")];
{
const h = await launch("6-same", { scenario: { knownSessions: ["fake-old-1"] }, env: { OPENRIG_RESUME_REF: "fake-old-1" } });
const record = await ready(h);
const call = reopenCalls(h)[0];
r.check("same: the agent is asked to reopen OPENRIG_RESUME_REF", call?.msg.params?.sessionId === "fake-old-1",
call ? `asked for ${call.msg.params?.sessionId}` : "no session/load or session/resume");
r.check("same: in the launch's working folder", call && realpathSync(String(call.msg.params?.cwd ?? "")) === realpathSync(h.cwd),
`cwd=${call?.msg.params?.cwd}`);
r.check("same: the record reports that conversation", record?.nativeRef === "fake-old-1", `record: ${JSON.stringify(h.record())}`);
r.check("same: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
{
const h = await launch("6-missing", { scenario: { reopenError: "missing" }, env: { OPENRIG_RESUME_REF: "fake-gone" } });
const record = await waitFor(() => h.record());
r.check("missing: the agent's own not-found is reported as error: missing", record?.error === "missing" && !record.nativeRef,
`record: ${JSON.stringify(record)}`);
await sleep(scale(800));
r.check("missing: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
{
const h = await launch("6-other", { scenario: { reopenError: "error" }, env: { OPENRIG_RESUME_REF: "fake-elsewhere" } });
await waitFor(() => reopenCalls(h)[0] && h.log().some((e) => e.dir === "out" && e.msg.error));
await sleep(scale(2500));
const record = h.record();
r.check("other failure: not reported missing or resumed", !record || (record.error && record.error !== "missing" && !record.nativeRef),
`record: ${JSON.stringify(record)}`);
r.check("other failure: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
return r;
},
async "7"() {
const r = result("7", "Death mid-turn: Stop/<preset>_exited, never an idle report");
const h = await launch("7", { scenario: { turns: [{ exit: true }] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "this turn kills the agent"));
const died = await waitFor(() => h.events("exit")[0]);
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
return r;
},
async "7b"() {
const r = result("7b", "Errors: a failed prompt is a runtime error, not a permission request");
const h = await launch("7b", { scenario: { turns: [{ error: true }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "this prompt fails"));
const posted = await waitFor(() => h.hooks("Notification", "runtime_error")[0]);
r.check("a failed session/prompt posts Notification/runtime_error", posted,
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
r.check("it isn't reported as a permission request", h.hooks("Notification", "permission_prompt").length === 0, "permission_prompt posted");
return r;
},
// Section 9 marks this duty "(Proposed)", and a runner may list in `limits` what keeps running, so the result is reported
// on its own: never a pass or a fail, and never the exit status.
async "7c"() {
const r = result("7c", "Shutdown: closing the pane ends the agent (proposed in section 9; advisory)", { proposed: true });
const h = await launch("7c", { scenario: { stubborn: true } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const pid = h.events("started")[0]?.pid;
h.stop();
const gone = await waitFor(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, scale(5000));
r.check("closing the pane ends the agent process", gone, `agent pid ${pid} still running ${scale(5000) / 1000} s later; list it in limits if that's expected`);
if (!gone) try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
return r;
},
async "8"() {
const r = result("8", "Unknown requests from the agent get -32601, and the runner carries on");
const h = await launch("8", { scenario: { unknownRequest: true } });
const answer = await waitFor(() => h.answerTo("fake/unknown_method"));
r.check("the unknown request is answered -32601", answer?.msg.error?.code === -32601, `answer: ${JSON.stringify(answer?.msg ?? null)}`);
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "still working?"));
r.check("the next prompt still goes through", await waitFor(() => h.prompts().length === 1), "no prompt after the unknown request");
return r;
},
async "9"() {
const r = result("9", "First input: OPENRIG_FIRST_INPUT's exact bytes are the first prompt");
if (!opts.firstInput) return r.skip("the preset doesn't declare first_input (--first-input): OpenRig passes no OPENRIG_FIRST_INPUT");
const dir = mkdtempSync(join(tmpdir(), "runner-check-first-"));
const file = join(dir, "first-input");
const text = "Read the plan first.\n\n Then say ready.\n";
writeFileSync(file, text, { mode: 0o600 });
const h = await launch("9", { env: { OPENRIG_FIRST_INPUT: file } });
const first = await waitFor(() => h.prompts()[0]);
r.check("the first prompt is the file's bytes, newlines and spaces included", first === text, `got ${JSON.stringify(first)}`);
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
return r;
},
};
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
const selected = opts.cases ?? order;
const unknown = selected.filter((id) => !order.includes(id));
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
let results;
try {
results = await Promise.all(order.filter((id) => selected.includes(id)).map(async (id) => {
try { return await cases[id](); } catch (error) { return { id, title: id, status: "fail", checks: [{ name: "the case ran", ok: false, detail: error.message }] }; }
}));
} finally {
tmux("kill-server");
server.close();
// A runner or agent can still be writing its last lines as its pane closes, so cleanup waits a moment and never fails the report.
await sleep(scale(300));
if (!opts.keep) for (const h of launches) try { rmSync(h.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* left in the temp folder */ }
}
// A proposed duty's result is advisory: it says whether the runner met it, outside pass, fail and the exit status.
for (const r of results) if (r.proposed && r.status !== "skip") { r.met = r.status === "pass"; r.status = "advisory"; }
const summary = Object.fromEntries(["pass", "fail", "skip", "advisory"].map((s) => [s, results.filter((r) => r.status === s).length]));
if (opts.json) {
console.log(JSON.stringify({ check: "openrig-runner-conformance", format: 1, runner: opts.runner, name: opts.name, results, summary }, null, 2));
} else {
console.log(`OpenRig runner conformance check (docs/reference/harness-runner-contract.md)\nrunner: ${opts.runner}\npreset: ${opts.name}\n`);
for (const r of results) {
const label = r.status === "advisory" ? "NOTE" : r.status.toUpperCase();
const outcome = r.status === "advisory" ? (r.met ? ": met" : ": not met") : r.reason ? `: ${r.reason}` : "";
console.log(`${label.padEnd(5)} ${r.id.padEnd(3)} ${r.title}${outcome}`);
for (const c of r.checks.filter((c) => !c.ok)) console.log(` x ${c.name}: ${c.detail}`);
}
console.log(`\n${summary.pass} pass, ${summary.fail} fail, ${summary.skip} skipped, ${summary.advisory} advisory (not counted)${opts.keep ? `; launch folders kept under ${tmpdir()}` : ""}`);
}
process.exit(summary.fail ? 1 : 0);
@@ -1,143 +0,0 @@
#!/usr/bin/env node
// A scriptable fake ACP agent for the runner conformance check (check-runner.mjs). It speaks just enough of ACP v1 over
// stdio, newline-delimited JSON-RPC, to exercise a runner without a model, credentials or the network. It was written
// after the fake agent in bpamiri/dsh-seat (Apache-2.0), which showed the approach.
//
// node fake-acp-agent.mjs --scenario <file.json> --log <file.jsonl> [any arguments the runner adds are ignored]
//
// The scenario says how to answer; the log records every message in and out, so the check can compare them.
// sessionId the id `session/new` returns (default "fake-session-1")
// initialize "error" or "exit" to fail the handshake there
// newSession "error" or "exit" to fail `session/new`
// newDelayMs wait this long before answering `session/new`
// knownSessions ids `session/load` and `session/resume` reopen
// reopenError "missing" (the agent's own "no such session") or "error", for an id it doesn't know
// unknownRequest send the runner a request with a method it can't know, once, after `initialize`
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
// waitMs stop waiting for the answer after this long, and end the turn
// exitAfterMs exit with status 3 this long after asking, while the request waits
// A `session/cancel` while the request waits ends the turn "cancelled" once it's answered.
// error answer the prompt with a JSON-RPC error
// exit exit with status 3 instead of answering
// stopReason the stop reason to return (default "end_turn")
import { appendFileSync, readFileSync } from "node:fs";
import { createInterface } from "node:readline";
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; };
const scenarioFile = arg("--scenario") ?? process.env.RUNNER_CHECK_SCENARIO;
const logFile = arg("--log") ?? process.env.RUNNER_CHECK_AGENT_LOG;
const scenario = scenarioFile ? JSON.parse(readFileSync(scenarioFile, "utf8")) : {};
const log = (entry) => { if (logFile) appendFileSync(logFile, `${JSON.stringify({ at: Date.now(), ...entry })}\n`); };
const send = (msg) => { log({ dir: "out", msg }); process.stdout.write(`${JSON.stringify(msg)}\n`); };
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
const exit = (status, why) => { log({ dir: "event", event: "exit", status, why }); process.exit(status); };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2) });
// Ctrl-C is the runner's to handle, so the agent never takes it as a signal. A stubborn agent also outlives a hangup and
// its input closing, as some real agents do: only the runner ending it (SIGTERM or SIGKILL) stops it.
process.on("SIGINT", () => log({ dir: "event", event: "signal", signal: "SIGINT" }));
process.on("SIGHUP", () => scenario.stubborn ? log({ dir: "event", event: "signal", signal: "SIGHUP" }) : exit(0, "SIGHUP"));
process.on("SIGTERM", () => exit(0, "SIGTERM"));
let sessionId;
let turn = 0;
let nextId = 1;
const answers = new Map();
let cancelTurn = null;
// Numbers, as JSON-RPC agents commonly use. A runner passes one on unchanged, and OpenRig reads it as its decimal string.
function ask(method, params) {
const id = 1000 + nextId++;
send({ jsonrpc: "2.0", id, method, params });
return new Promise((resolve) => answers.set(id, resolve));
}
async function prompt(id, params) {
const spec = (scenario.turns ?? [])[Math.min(turn, (scenario.turns?.length ?? 1) - 1)] ?? {};
turn += 1;
if (spec.exit) exit(3, `turn ${turn}`);
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
send({ jsonrpc: "2.0", method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
let stopReason = spec.stopReason ?? "end_turn";
if (spec.permission) {
const p = typeof spec.permission === "object" ? spec.permission : {};
const asked = ask("session/request_permission", {
sessionId: params.sessionId,
toolCall: { toolCallId: `call-${turn}`, title: "Edit notes.txt", kind: "edit" },
options: p.options ?? [
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
],
});
if (p.exitAfterMs) setTimeout(() => exit(3, "while a permission request waits"), p.exitAfterMs);
let cancelled = false;
cancelTurn = () => { cancelled = true; };
const answer = await (p.waitMs ? Promise.race([asked, sleep(p.waitMs).then(() => null)]) : asked);
cancelTurn = null;
if (!answer) log({ dir: "event", event: "permission-wait-ended" });
if (cancelled) { reply(id, { stopReason: "cancelled" }); return; }
}
if (spec.holdMs) {
const cancelled = await new Promise((resolve) => {
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
cancelTurn = () => { if (spec.ignoreCancel) return; clearTimeout(timer); cancelTurn = null; resolve(true); };
});
if (cancelled) stopReason = "cancelled";
}
reply(id, { stopReason });
}
async function reopen(id, params) {
if ((scenario.knownSessions ?? []).includes(params.sessionId)) { sessionId = params.sessionId; reply(id, null); return; }
if (scenario.reopenError === "missing") fail(id, -32002, `Session not found: ${params.sessionId}`);
else fail(id, -32603, "fake agent: reopen failed");
}
async function handle(msg) {
if (msg.id !== undefined && msg.method === undefined) { answers.get(msg.id)?.(msg); answers.delete(msg.id); return; }
switch (msg.method) {
case "initialize":
if (scenario.initialize === "exit") exit(2, "initialize");
if (scenario.initialize === "error") { fail(msg.id, -32603, "fake agent: initialize fails"); return; }
reply(msg.id, { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] });
if (scenario.unknownRequest) await ask("fake/unknown_method", { note: "a method no runner knows" });
return;
case "session/new":
if (scenario.newDelayMs) await sleep(scenario.newDelayMs);
if (scenario.newSession === "exit") exit(2, "session/new");
if (scenario.newSession === "error") { fail(msg.id, -32603, "fake agent: session/new fails"); return; }
sessionId = scenario.sessionId ?? "fake-session-1";
reply(msg.id, { sessionId });
return;
case "session/load":
case "session/resume":
await reopen(msg.id, msg.params ?? {});
return;
case "session/prompt":
await prompt(msg.id, msg.params ?? {});
return;
case "session/cancel":
cancelTurn?.();
return;
default:
if (msg.id !== undefined) fail(msg.id, -32601, `Method not found: ${msg.method}`);
}
}
const lines = createInterface({ input: process.stdin });
lines.on("line", (line) => {
let msg;
try { msg = JSON.parse(line); } catch { log({ dir: "event", event: "garbage", line }); return; }
log({ dir: "in", msg });
void handle(msg);
});
lines.on("close", () => { if (scenario.stubborn) { log({ dir: "event", event: "stdin-closed" }); setInterval(() => {}, 60_000); } else exit(0, "stdin closed"); });
-6
View File
@@ -165,12 +165,6 @@ commit. Evidence capture (`proof add`), policy acceptance, higher outcome
judgment and publication are distinct. This optional component does not select
Part B or add a mandatory review gate.
A judgment binds the slice's path, the item's text and the policy. Moving or
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
current revision. `rig scope audit` checks structure only and never reads proof
readiness, so a passing audit doesn't mean the proof still stands.
The project readiness that `rig proof show --project` reports counts only
missions whose `mission.yaml` carries `metadata.status: active` or
`metadata.status: release-candidate`. `rig scope mission create` writes
+1 -16
View File
@@ -16,28 +16,13 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
`--dangerously-interact --reason` remains available at every interruption level
and uses the existing-input check below. Direct human input remains available.
A message sent to a busy Codex seat joins its running turn and may redirect it.
For separate work, create a queue row instead:
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
A row that's saved keeps the work apart from its wake, the short pointer typed
into the seat. The wake is still typed input, and it isn't proof that the seat
claimed or finished the work: the row's state says that.
`rig send <seat> "<message>" --wait-for-idle <seconds>` suits a sender who
watches the result. If the seat is still busy, or its activity is unknown, when
the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps
nothing to send later.
For separate work, use `rig send <seat> "<message>" --wait-for-idle <seconds>`.
## Configure and inspect
-17
View File
@@ -1,17 +0,0 @@
# Seat settings by node ID
Clients that present a specific seat can save its later-launch settings using its durable `nodeId`. The node ID is distinct from the current session and native conversation. These routes never launch, stop, or resume the seat.
| Read or save | Node-addressed route |
| --- | --- |
| Status | `GET /api/seat/status/node/:nodeId` |
| Native permissions | `POST /api/seat/set-permissions/node/:nodeId` |
| Permission policy | `POST /api/seat/set-policy/node/:nodeId` |
| Model | `POST /api/seat/set-model/node/:nodeId` |
| Effort | `POST /api/seat/set-effort/node/:nodeId` |
The POST bodies and responses follow the corresponding name-addressed routes. A successful save's `seat.nodeId` and `seat.rigId` identify the node actually updated. The exact status response adds `node_id`, `contract: "seat-node.v1"`, and `supported_actions` listing the four setters. Existing name status responses retain their shape.
An unknown, deleted, or archived node returns `seat_not_found` (HTTP 404) at node resolution. After awaited validation of a native Claude permission mode or Codex profile returns, the exact node-ID path checks availability again before saving; an unavailable node then returns `seat_not_found` without a setting or audit write. Existing native validation may refuse a changed target before this check: for example, Claude's managed context guard can report `permission_selection_refused` during preparation. The daemon never resolves a node ID through a reusable seat name. Setting writes have no general session precondition; a changed occupant on the same available node can receive a later save.
For example, `POST /api/seat/set-model/node/<nodeId>` accepts the existing `{ "model": "...", "reason": "...", "operator": "..." }` body. The existing audit actor, reason, native validation, permission precedence, and terminal policy rules still apply. The older `/:seatRef` routes remain available, including their name ambiguity behavior.
+2 -28
View File
@@ -58,15 +58,6 @@ not fill in; if so, follow the form.
one, it is refused.
10. Run `rig slack setup --channel <channel-id> --secrets-env-file <path>`, then
`rig slack verify`, then `rig slack enable`.
11. Enabling treats the asks already open as seen, so none of them post. To see them in Slack, run
`rig slack post-open-asks`. Each goes through ordinary delivery at the level it was asked at
(quiet hours, digest and the posting floor apply), and the command says what became of each:
posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer
active, superseded, recipient changed, state unavailable, transport failed or uncertain. An ask
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
## What the app asks for
@@ -109,14 +100,6 @@ reaction on any other message, including a person's reply in the thread, is igno
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
reinstall the app to the workspace.
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
back with `--reactions route` needs no change to the app. Like any saved change, the setting reaches
a running connector when it next rewires, which a daemon restart does (`rig daemon stop`, then
`rig daemon start`). See [One channel per rig or seat](#one-channel-per-rig-or-seat-optional) for
why disabling and re-enabling the connector isn't the same.
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
clicks arrive over the same socket, so no request URL is needed. An app created from an older
@@ -199,14 +182,6 @@ rig slack channel-map remove pr@my-rig
- **Answers come back to where the person asked.** A person's new message is the root of its
answer: when the seat it reached replies with `--reply-to` that message's row, the answer and
later updates land in the person's thread, in the channel they used.
- **One conversation, whoever writes.** Messages routed to the same continuing agent
conversation can use that conversation's earlier context, whoever sent them. A queue row
records who sent a message and which seat owns it; it doesn't start a separate agent
conversation. If people need separate conversations, route them to separate seats
(`--inbound` names the seat a channel's new messages reach). Separate channels help only when
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel only.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
@@ -215,8 +190,7 @@ app is in all of them. A saved change reaches a running connector when it next r
daemon restart does (`rig daemon stop`, then `rig daemon start`). `rig slack disable --reason
"<why>"` then `rig slack enable` rewires too, but alerts still waiting to be posted when it's
re-enabled are treated as already seen and aren't posted. Only an earlier post that failed or
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. `rig slack post-open-asks`
then shows the asks still open. The TUI's Connections page says whether the running
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. The TUI's Connections page says whether the running
configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
@@ -284,7 +258,7 @@ reaches a running connector when it next rewires, which a daemon restart does (`
then `rig daemon start`). `rig slack disable --reason "<why>"` then `rig slack enable` rewires too,
but alerts still waiting to be posted when it's re-enabled are treated as already seen and aren't
posted. Only an earlier post that failed or didn't finish still posts: an alert whose row is still
open, a decision-resolved notice, or a digest. `rig slack post-open-asks` then shows the asks still open.
open, a decision-resolved notice, or a digest.
## One app across machines (optional)
+4 -333
View File
@@ -30,44 +30,16 @@ With neither a provider nor a session, the daemon's open and preview APIs try
a live Herdr first, then a live cmux. An explicit session always selects Herdr
and skips that fallback, including when the daemon's default Herdr endpoint is down.
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
when unset. Relative paths (including an empty value) resolve from the daemon's
startup directory, matching Herdr's configuration selection. Named sessions live under
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
These settings are captured when the daemon starts.
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
configuration-tree path, without reusing the override or inferring a session
from an arbitrary socket path. With an explicit socket but no configured
session name, omit `--session` to keep that exact endpoint. A missing selected
socket never falls back to another session. Viewer and follow operations carry
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
CLI `--session` that would override it. They check that the CLI reports the
selected socket before changing its panes. Daemon-side CLI calls retain the
startup environment; desktop follow uses the socket confirmed by the daemon.
The selection belongs to the request. Saved views still describe membership
and layout, so pass `--session` again when reopening the view in that session.
An explicit session resolves under the daemon user's Herdr session directory;
it takes precedence over that daemon's default socket override. The selection
belongs to the request. Saved views still describe membership and layout, so
pass `--session` again when reopening the view in that session.
The existing view rules apply: saved views, rig and pod views, read-only mission
and slice views, pagination, absent seats and partial results all use the same
composer. Selecting a session changes where those tiles are placed. It does
not relaunch the seats' tmux sessions or move queue ownership.
## Open results
For Herdr, a successful layout request alone does not count its seats as opened.
OpenRig checks the returned tab's pane list. A missing tab identity or an
unreadable, empty or unattributable listing puts the affected seats in
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
the layout may have taken effect even though its panes could not be confirmed.
Confirmed seats on other pages still count; with none confirmed, the command
returns a nonzero exit status. Reusing an existing workspace keeps its separate
reuse result. This pane check does not prove desktop visibility or create a
verified viewer mapping.
## Replacing a seat's session
New tiles keep their place when a seat's tmux session is replaced. They wait up
@@ -167,304 +139,3 @@ session keeps the normal HTTP 200 provider result with `ok: false`, its selected
`session`, an error code and direct-attach guidance.
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
reports that known failure instead of replacing it with an unconfirmed-session error.
## Task terminal views
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
view of the current owners of a local queue episode. The episode includes the
requested row and explicitly linked descendants; only pending, in-progress and
blocked obligations contribute owners. It does not include historical senders,
closed predecessors, parent episodes or siblings. Membership is not input
permission.
The existing daemon endpoints accept the same view:
```text
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {kind:"qitem", id, qitemId, name, complete, issues, members}`. Each member
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
external and unresolved remote owners remain visible. Remote queues are not
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
explicitly incomplete; it is never silently presented as the whole team.
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
It reads one snapshot from the requested task forward, then probes earlier active
obligations using the queue's existing state index and follows references by exact
ID, including closed intermediaries. A child may arrive before its referenced root.
All these reads share the work limits; unrelated older closed history is not scanned.
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
are supported.
Hitting a work limit preserves known members and reports incomplete membership;
it does not claim that an owner outside the scanned history is absent.
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
derived views and `fixed` for a saved layout. A task uses its row summary as
its name, falling back to the qitem ID. The existing preview `view` string is
unchanged. A following view derives membership when requested; it does not
mean an open workspace updates itself.
Preview is read-only. A member without owned viewer provenance is
`not-provisioned`; the ensure operation below can establish a reusable provider
mapping. An explicit open retains the provider's existing workspace behavior;
it does not continuously reconcile the view or change an agent's lifecycle.
## Following one task
`rig terminal open qitem:<id> --follow` keeps one workspace following the
task's current obligation owners. A joining member gets a verified local
viewer tile without taking focus. A departing member loses only its viewer,
after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
endpoint. Other platforms/providers and other selector kinds retain ordinary
one-shot open. Use `--provider herdr --json` to operate without opening a
desktop window, and `--session <name>` to select that endpoint. Opening a tile
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
```bash
rig terminal open qitem:example --follow --provider herdr --json
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
rig terminal open qitem:example --no-follow
```
`--unpin` and `--unsuppress` reverse those presentation choices. The default
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
exceed the cap rather than being evicted. A departed tile retained for typed or
unconfirmed input does not consume a slot for joining members. Disabling removes
the saved following selector and keeps the existing layout intact.
The default departure grace is four seconds (`--grace-ms`). A focused leaver
gets another twelve seconds for reading (`--focus-retention-ms`); before its
tile closes, focus moves to a verified surviving member, or to the explanation
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
as no longer participating. Explicit suppression also retains a typed draft.
Pi, terminal and community harness input may be unreadable to the composer
inspector: those tiles remain while input is unconfirmed, even after unpinning.
Each input-retained departure gets one delayed recheck after thirty seconds.
After that, queue or node/session lifecycle events, or an explicit repeat of
the follow command, can reconsider it. Activity hooks do not wake following,
and input is not repeatedly polled. Draft text is never saved.
Following consumes Core events and one-shot retention/suppression deadlines,
not repeated CLI membership queries. Complete empty membership drains the
tiles and preserves the workspace. Once no retained tiles remain, following
stops and the selector releases its active slot; new work does not automatically
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
or changed pane/terminal generation retains the previous view and reports
the uncertainty. It never authorizes a blind workspace rebuild or close.
`rig terminal views --json` and `rig terminal status --json` include the saved
active selectors and latest following status. Active selectors and owned viewer provenance
survive daemon restarts; task membership is always re-derived.
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
Seat controls are arrays of names from the current/retained view. It uses the
existing terminal bearer boundary, like ensure-viewer. The response includes
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
provenance grants no membership or input authority. This is a local native
viewer operation; browser input and remote-host provisioning are separate.
## Ensuring an owner viewer
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
expectedPlan}`. Use a current preview's member key and plan. This explicit
operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. A lost create
reply is reconciled before starting anything else; a lost run reply is checked
against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
viewer status. Studio can ensure a new member, add its browser frame, and retain
a pinned leaver labeled as no longer participating. The provider mapping is
small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
## Following a slice or mission's current work
Use `rig terminal open slice:<name-or-id> --follow --provider herdr` to keep
one workspace following the current destination owners of that slice's active
queue rows. `pending`, `in-progress` and `blocked` rows count. A member can own
several rows; `task.members[].obligations` preserves every row and state.
A slice resolves within the daemon's configured slice index, by exact directory
name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
replaced; merely linking an untagged child does not associate it. A mission-only
tag does not put a row in every slice. No project is inferred from a tag or the
caller's working directory. A bare name or ID claimed by multiple slices is
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
still follow a slice whose other alias is reused: a row carrying that colliding
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
Rows placed in another mission are excluded. Missing, contradictory or otherwise
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
viewers. A unique alias on its own retains exact matching. Flat legacy slices
without an indexed parent mission cannot disambiguate a colliding alias.
Mission views use `mission:<directory-id>`, from the same configured index.
A mission is known when its directory contains `slices/` (even empty) or a
readable `mission.yaml` declaration. Its directory ID is its exact identity;
mission frontmatter is not an alias. Membership is the union of active rows
with that exact `mission:` tag and rows associated with any physically indexed
child slice by its exact name or unique ID. Each row counts once even when it
has both tags. Flat legacy slices without that parent association are not
inferred into a mission. A child's historical status or composition activation
flag does not remove its current obligations from the union. Duplicate mission
IDs are ambiguous. Shared child names/IDs are omitted from bare slice matching;
rows with one indexed claimant mission are placed through their mission tag.
A direct mission tag takes precedence, including work tagged to both missions.
Without that direct tag, a shared-key row with missing, contradictory or
non-claimant mission tags is named in `issues` and makes the snapshot incomplete;
known owners remain.
The launcher reads this same terminal preview and submits its `expectedPlan`
when opening, rather than using the review-agent roster.
Slice and mission keys are case-sensitive and can include Unicode, plus signs and internal
spaces. They cannot be empty or contain slash, backslash, control characters,
or leading/trailing whitespace in the scope reader. The service trims the whole
selector before composition, so a trailing selector space is accepted by
preview/ensure with the same plan; ensure rejects a leading selector space.
Prefer the exact canonical selector in every call. Pass it in JSON or a quoted
CLI argument; encode it once as a GET query value. Do not double-decode it.
Slice previews and open results use `task: {kind:"slice", id, name, complete,
issues, members}`. `id` is the unique frontmatter id, or the directory name when
no id is present. There is no synthetic `qitemId`. Preserve the preview's `view`
for the ensure request. All successful previews, including qitem previews,
include `capabilities: {ensureViewer:boolean}`. True means that the resolved
view/provider implements ensure; the existing member, completeness, owner and
`expectedPlan` checks still apply. A missing capability/task on an older daemon
is unsupported scoped membership, not an empty team.
Mission results use the same shape with `task.kind:"mission"`; `task.id` and
`task.name` are the directory ID, with no synthetic `qitemId`. Ordinary mission
preview/open uses this current-obligation membership too: past participants
without active obligations are excluded, while unavailable current owners
remain named. Follow, preview and ensure share the same authority.
New slice and mission workspaces default to four automatic tiles; `--max-tiles` overrides
that. Qitem views retain their eight-tile default. The full membership and
obligation list remains available when tiles exceed capacity. Pins, drafts,
unknown input and focus retention still protect departed viewers. Closing a
viewer never stops its agent. Opening a tile can change the agent's dimensions.
A known empty slice or mission remains enabled in the same workspace; later associated
work can join it. An incomplete queue read (`task.complete:false` plus `issues`)
retains previous viewers. A missing scope is `scope_not_found`, duplicate scope
identity is `scope_ambiguous`, unreadable/capped index data is `scope_unavailable`,
and an unavailable resolver is `scope_unsupported`. None is authoritative empty.
Ensure of an incomplete snapshot returns `viewer_unconfirmed`; changed plans
return `preview_changed` before provisioning.
Ordinary ownership changes follow queue/session/node events. Node and directory
changes under the primary configured index use the existing filesystem watcher,
with a coalesced scope invalidation event. There is no repeating membership
poll. Structural edits under additional legacy roots, or a filesystem where
watching is unavailable, require `rig`'s existing slice refresh endpoint
`POST /api/slices/refresh` or reopening the view. Missing or unreadable sources
keep prior viewers; they never certify that the work disappeared.
Scope identity reads are bounded to 4,096 directory entries, 16 KiB per selected
node-header read, and 8 MiB of source bytes per index generation. The byte budget
counts the bounded read, including text after short frontmatter. An index that
exceeds these limits is too large for this read; use a smaller configured index
or a qitem view. This is separate from unreadable or malformed sources.
Queue membership reads are bounded to 50,000 active rows plus one lookahead,
16 KiB of encoded tags per row and 8 MiB of aggregate encoded tags. They remain
fresh on each render. Identities are reused within a scope-source generation
only while the primary-root watcher is live and existing additional roots are
covered by that root. A source event or explicit refresh invalidates them;
errors discard the cache. Unavailable watching or an existing unwatched legacy
root uses fresh bounded identity reads instead. Missing legacy roots are checked
on each call so an appearing root cannot reuse an older cached identity set.
Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
An incomplete queue snapshot is a successful preview with `task.complete:false`;
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
ensure view kinds now return `view_required` (400), where they previously returned
200 with an error body. Unsupported follow view kinds
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
preview remains `preview_changed` (409). During automatic refresh a missing or
ambiguous scope retains the existing tiles and names the source problem in the
explanation strip; refresh or reopen it under the current unambiguous key.
## Reading or sending to an expected native conversation
An application that selects an original Claude Code or Codex conversation can opt
into an expected-identity check. The identity is the exact tuple `nodeId`, canonical
`sessionName`, `runtime` (`claude-code` or `codex`) and native `nativeId`. A registry
resume token by itself is not a current-process identity observation.
Read `GET /api/sessions/:sessionName/generation-record-expected` with query fields
`expectedNodeId`, `expectedRuntime`, `expectedNativeId` and optional `sinceBytes`.
It resolves the verified current occupant and native process, opens that native
record, checks its identity, and returns identity and bounded suffix bytes from
the same opened file. Its existing byte cursor and `generationId` semantics match
`generation-record`. Success adds `identityContract:1` and `checkedIdentity`, the
exact expected tuple. Check both before displaying the suffix as that conversation.
Send `POST /api/transport/send-expected` with the ordinary send body and a required
`expectedIdentity` object containing those four fields. The check runs again after
buffer preparation immediately before paste, and before Enter after any wait.
A retained input stores the constraint in its outbox receipt; committed dispatch
and reuse of its delivery ID cannot select a different original conversation or
omit the stored constraint. Delivery IDs still do not deduplicate explicit input.
Success adds `identityContract:1` and `checkedIdentity`. Retained is not sent;
`checkedIdentity` is an observation at an input boundary, not model consumption.
If replacement is observed after paste, the result reports `inputPhase:"written"`
and `sent:true`, with Enter refused. Inspect that input before deciding what to do.
Malformed expectations return 400 (`invalid_expected_identity`). A different
observed identity returns 409 (`expected_identity_mismatch`); a missing or
unverified identity returns 409 (`expected_identity_unavailable`). Conflicting
reuse of a delivery ID returns 409 (`delivery_identity_conflict`). No conditional
operation falls back to the replacement. Use these dedicated paths when detecting
support: older daemons return 404 instead of silently ignoring an unknown field.
Ordinary reads and sends without an expectation or an ID bound by an earlier
conditional send keep their existing behavior. Reusing a bound ID also carries
its stored constraint when the caller omits the expectation.
While a runtime switch is preparing or confirmed, a conditional send returns
409 `expected_identity_unavailable` with `sent:false` and `inputPhase:"not_reached"`.
It retains neither the input nor the expectation. Retry the conditional endpoint
with the original `expectedIdentity`; an ordinary send is not an identity-bound
retry unless its ID already has a stored constraint. Ordinary sends during the
switch keep their existing retained response.
This is a finite process/record observation followed by a terminal write; tmux has
no atomic native-conversation compare-and-write operation. A native conversation
change after the final observation remains a race. The check does not turn a
terminal attachment into a conversation pin: existing terminal viewers follow
managed pane/session bindings and may display their replacement. Applications
must keep that attachment limit separate from checked record reads and sends.
-166
View File
@@ -1,166 +0,0 @@
# Choosing the tmux server for an OpenRig instance
`terminal.tmux_server` controls where the daemon creates and reads managed seats.
It accepts `inherit` (the default) or `isolated`. The environment override is
`OPENRIG_TMUX_SERVER`.
To keep managed seats out of your personal `tmux ls`, set this before starting a
new instance, or before a deliberate restart of an existing daemon:
```sh
rig config set terminal.tmux_server isolated
```
The setting is read once at daemon startup. Changing it does not move a running
seat. Inspect the effective choice with:
```sh
rig terminal status --json
```
The `tmux` object reports `requestedMode` (read at the last startup), effective
`mode`, `socketPath`, `serverArgs`, `migrationPending`, and `blockingSessions`.
The settings browser shows the configured value; terminal status shows what the
running daemon actually uses.
## Server identity and configuration
In `inherit` mode, a new instance uses the launching shell's `TMUX` socket, or the
default socket under `TMUX_TMPDIR` (otherwise `/tmp`) when outside tmux. Native
commands retain tmux's existing environment-based selection. Printed attachments
and terminal metadata name a non-default server so a new terminal can reach it.
The default mode does not require a server record or an additional instance
identity check to start the daemon or use existing attachment paths.
In `isolated` mode, the socket is named `openrig-<hash>` under tmux's per-user
directory. The hash identifies the canonical `OPENRIG_HOME`: different homes get
different servers, and symlink aliases of one home share a server. Choose a short
`TMUX_TMPDIR` before the first isolated start if your normal temporary path would
exceed the platform's Unix socket path limit.
When isolation is requested or active, the daemon records the effective socket in
`OPENRIG_HOME/tmux-server.json` using an atomic, owner-only write. Keep this file
and the durable `tmux-server.initialized` marker with the instance state. A later shell
with different `TMUX` or `TMUX_TMPDIR` does not redirect an established isolated
instance. An invalid or unreadable isolation record is reported rather than overwritten.
If the record disappears while isolation is selected, startup and `destroy --all` stop before
touching sessions. Restore the record from an instance backup, or inspect the
running daemon's `rig terminal status --json` to recover its effective selection.
An ordinary inherited instance may record its socket for a later migration, but
missing, invalid or unwritable records do not prevent its normal startup.
The record's `pinned` flag distinguishes a fixed isolated or migration socket
from an advisory inherited record. Returning to ordinary inheritance commits
`pinned: false` and removes the active marker; an interrupted update must not
leave startup using one server and offline cleanup selecting another. A valid
unpinned record takes precedence over a leftover marker. Legacy inherited records
and the old `1` marker do not make ordinary inheritance depend on that file.
A newly started server loads tmux's normal system and user configuration,
including `~/.tmux.conf`; OpenRig does not substitute an empty configuration.
Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
If registered sessions still exist there, it keeps that server and reports a
pending migration. Explicit `OPENRIG_HOME` ownership markers distinguish another
instance's namesakes; unmarked legacy sessions are retained conservatively. If
the old server cannot be inspected, the daemon also keeps it and reports why.
Stop the listed sessions deliberately, then restart the daemon again to apply the
new selection. Merely editing the setting or waiting for the last seat to stop
does not switch a running daemon. The same rule applies when returning to
`inherit` from isolation. A normal inherited start continues to follow its launching
environment, as before. No
live pane is moved, restarted or killed by server selection. Old viewing windows
can be closed separately after their conversations have stopped.
Bring a stopped team back with `rig up <name> --existing`. Its normal restoration
rules still apply: a seat without usable provider history requires an explicit
fresh-start choice before it launches.
On the first upgrade from a version without a socket record, the old-server
candidate comes from the launching environment. If live seats use a non-default
server, start the upgraded daemon from that original server to establish its
record, or stop those seats before changing servers. Earlier versions did not
persist enough information to find an arbitrary old socket from a different shell.
## Attachments, discovery and cleanup
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
use the effective server. `rig ps --nodes --json` includes `tmuxServerArgs` and a
matching `tmuxAttachCommand` for bound tmux seats. `rig tui --shared` uses that
metadata. To attach a terminal directly, use the canonical session name:
```sh
rig terminal attach '<session>'
rig terminal attach --read-only -- '<session>'
```
These commands attach on the daemon's machine. They do not create a seat or
switch somebody else's tmux client. A local CLI pointed at an HTTP remote daemon
cannot attach a same-named local session. For an effectively isolated server,
terminal attachment, shared TUI and desktop windows also compare the daemon's
instance identity with the local durable identity before using local terminal
tools. A loopback URL through a port forward does not bypass this isolation check;
use the matching `OPENRIG_HOME` on the daemon's machine. Inherited servers retain
the existing local-host checks without requiring a matching durable identity.
SSH view tiles follow the viewing instance's effective mode. In `inherit`, they
use remote tmux through `ssh -t`, including `-r` for read-only views, and keep the
bounded, identity-checked reattachment when a seat's session is replaced. The
remote host needs only tmux and a POSIX shell, not OpenRig. Copyable recovery
commands remain plain `ssh -t ... tmux attach`. In `isolated`, tiles run
`rig terminal attach` on the remote host,
using that host's local instance and socket; both the remote CLI and daemon must
support this command. The remote shell must select the right `OPENRIG_HOME` when
using a custom home. Local socket paths are never sent to it, and an isolated
attachment failure does not fall back to a same-named default-server session.
An inherited view does not discover an isolated remote instance's socket; use
the remote instance's `rig terminal attach` directly for that mixed setup.
Discovery and adoption see the selected server. Existing sessions on another
server are not imported automatically. `rig attach --self` reports its current
socket and, for an isolated instance, rejects a different server before binding
or adding a node; an older
CLI without socket metadata must be updated to self-attach to an isolated
instance. A shell outside tmux can still attach as `external_cli`.
`rig destroy --all` reads the recorded effective server before stopping the daemon
or removing its state when isolation or a deferred migration pins it. Ordinary
`inherit` cleanup uses the invoking environment's normal tmux selection, as before;
an old advisory socket is not used to redirect it. Cleanup kills only registered
session names on the selected server.
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
server behavior; requesting `isolated` produces a status note and a pending choice.
## Two instances on one machine
Isolation selects only a tmux server. Give each daemon its own OpenRig home,
database and port as well. For example, from a directory where you want to keep
the two instances:
```sh
mkdir -p .openrig-a .openrig-b
env OPENRIG_HOME="$PWD/.openrig-a" OPENRIG_DB="$PWD/.openrig-a/rig.db" \
OPENRIG_PORT=5311 OPENRIG_TMUX_SERVER=isolated rig daemon start
env OPENRIG_HOME="$PWD/.openrig-b" OPENRIG_DB="$PWD/.openrig-b/rig.db" \
OPENRIG_PORT=5312 OPENRIG_TMUX_SERVER=isolated rig daemon start
```
Use the same instance-specific environment for every subsequent command, including
`rig up`, `rig terminal status --json`, attachment and `rig daemon stop`. Do not
reuse a database between these homes. Other inherited `OPENRIG_*` variables still
apply; start from a shell without selectors for another running instance.
The operating-system home, provider credentials and provider conversation history
remain shared unless configured separately. This setting does not isolate the
filesystem or network. Both inherited and isolated servers support the opt-in
`OPENRIG_TMUX_CONTROL=1` execution path; its control client connects to the same
server as the daemon's other tmux commands. On servers older than tmux 3.7c, or
whose version cannot be recognized as a fixed release, OpenRig uses spawned
clients instead: starting a control client while another session is created can
crash those servers (#2304). This avoids creating OpenRig's own control client;
it cannot protect the server from other tools' control clients.
+262 -150
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.2
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.7.2. It lists only what behaves differently from
0.7.1 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,157 +9,279 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (132, 133,
134, 136, 138 and 139). They add records for runtime switches, stream reader actions, a seat's launch recipe, a Claude
seat's token reference and its tool rules, and rebuild the provider usage readings table to hold labelled Claude
token readings, keeping existing readings. No setting is added or removed.
- **The bundled openrig-core plugin goes from 0.1.18 to 0.1.19.** Only two skills changed: `delegating-work` (its
description now also covers deciding how far to trust a delegated result) and `openrig-user` (reading a queue write,
a seat's own Claude or Codex account, moving one seat to another login, and the MCP handoff and unclaim tools). As in
0.7.1, startup first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not,
and logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Startup also refreshes the person-facing `rigs` skill** in `~/.claude/skills` and `~/.agents/skills`, with the same
copy first, when OpenRig installed it (a copy installed another way is left as it is). It now says what setup and
Codex seats write to a Codex home, offers the operator conversation to a Codex user who declined Codex's remembered
command rule, and covers Rex.
- **Codex seats:** OpenRig's Codex plugin is openrig-core, so Codex sees it as changed. As in 0.7.1, OpenRig renews
its own hook approval only where the installed plugin folder matches what shipped. If a Codex seat stops at Codex's
"Hooks need review" after the upgrade, that folder held other files: review the hooks in Codex.
- **On Linux, check seats that use your session bus** (the keyring, notifications). From their next launch, restore or
handover they get `DBUS_SESSION_BUS_ADDRESS=disabled:` unless their `env:` sets an address (see Known gaps).
- **Launch fixes reach a seat at its next launch, resume, restore or handover,** not a running process: the Read
grants a managed Claude restore needs, a Codex seat's writable git and topology folders, a seat's own account home,
the Linux runtime directory and member tool rules.
- **Close and reopen terminal views opened before the upgrade** (Herdr, cmux or tmux). Only views opened on 0.7.2 keep
their tiles when a seat's session is replaced.
- **If a seat runs on a home given by `rig up --env CLAUDE_CONFIG_DIR=…` or `--env CODEX_HOME=…`,** declare it in the
member's or rig's `env:` instead. 0.7.2 leaves both names out of `--env`, with a note.
## New things you can do
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
- **Move a live seat between Codex and Claude Code:** `rig seat set-runtime <seat> --runtime <claude-code|codex>
--reason "…"`, with `--dry-run` first to see the target's settings, what is cleared and the costs. The successor
starts beside the current occupant, reads a handover (the seat's recap, its open rows and the predecessor's screen)
and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "…"`. Only then does the seat move, keeping
its address, connections, rows and claims. Without a confirmation in 180 seconds (`--confirm-timeout`) nothing
changes; `--recover` finishes an interrupted switch. The switch starts a new conversation and isn't written back to
the team file (`docs/reference/getting-started.md`, "Move a seat to the other runtime").
- **Run a seat on its own Claude or Codex account:** sign a home in once natively (`CLAUDE_CONFIG_DIR=<home> claude`
then `/login`, or `CODEX_HOME=<home> codex login`), then declare that absolute path as `CLAUDE_CONFIG_DIR` or
`CODEX_HOME` in the member's or rig's `env:`. Launch, resume, restore, handover, OpenRig's managed Codex config and
hooks, usage limits and status follow it. `rig seat status` and `rig provider status` show the home and where it came
from; a relative value is ignored and shown.
- **Move one seat to another login on the same conversation:** for a Codex seat on Linux, `rig provider precheck --seat
<seat> --to-account <name>`, then `rig provider switch --seat <seat> --to-account <name>`, using a login saved in that
seat's Codex home (`rig auth save`). It saves the outgoing login, activates the chosen one, restarts the seat in place
and prints a restart command for every peer on that home. For a Claude seat, `--to-account <label> --token-file
<absolute path>` moves it to its own subscription token (from `claude setup-token`, in a file only the user can read);
OpenRig keeps the path and label, never the token. `--stored-login` moves it back.
- **Read a labelled Claude token's usage:** `rig provider status --refresh --provider claude --claude-token-file
<absolute path> --credential-label <label>`. Each refresh spends one model turn; status and the TUI show only the
cached reading. The label is yours, not an account Claude reported.
- **Give each Claude member its own tool rules,** even in a shared folder: `claude_permissions: { allow: [...], deny:
[...] }` on the member, or `rig seat set-permissions <seat> --rules <json-file> --reason "…"` for an existing seat.
The update is audited, applies at the next managed launch or resume, and leaves the permission mode alone. Rules join
OpenRig's one `--settings` value and never go into the project's Claude settings. They're native tool rules, not OS
isolation, and an allow list doesn't withdraw broader inherited grants. Limits are under Known gaps.
- **Say what you did about a stream item:** `rig stream act <id>` with exactly one of `--work <qitem>` (work you opened
or joined through the queue), `--decline "<reason>"` or `--duplicate <qitem|stream id>`. Actions are append-only, the
poster's `rig stream show` lists each reader's current action, and retention keeps an item a reader acted on. A
reader who links different work from someone else is told about theirs.
- **Pass held work on over MCP:** `rig_queue_handoff` closes an item as handed off and opens its successor in one step,
and `rig_queue_unclaim` puts a claim back. The MCP server has 36 tools.
- **Release a row parked on a typed blocker:** `rig queue resolve <id> --decision "<evidence>"` now also unparks a row
blocked on `external:`, `fold:` or `auth:`, and records the evidence as a wait resolution, not a person's decision.
- **Open views in Rex:** from a Rex pane, `rig terminal open <view> --window` or the TUI opens the view as a new Rex
session and switches the Rex app to it. `--provider rex` picks it explicitly. Outside Rex the default stays Herdr,
then cmux (`docs/reference/terminal-sessions.md`). Not run on Linux.
- **Hand a community harness its first turn at launch:** a preset's `first_input: true` writes the first startup text
to a private file and passes `OPENRIG_FIRST_INPUT=<path>`; the launcher hands it to the harness. The Cursor example
uses it and now sets `startup_assisted: false`. OpenClaw gains a Gateway-client example (`openclaw-gateway.yaml`)
beside the local one, and both resume a named conversation.
- **Start one seat of a materialized team:** after `rig import <spec> --materialize-only`, `rig launch <rig> <logical
id>` (for example `dev.lead`) starts only that never-started seat. Teams materialized by older daemons aren't covered.
- **Clear a prompt a hook still names:** once `rig capture <seat>` shows it's gone, `rig seat clear-attention <seat>
--reason "<what you saw>"` clears a retained permission or waiting hook, so sends and wakes go through. A refusal that
rests on such a hook now names this step.
## Behaviour that changed
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
- **A working Claude seat that quotes approval text** in its output no longer refuses sends and wakes as if it were at
a prompt, including when its input box is drawn high in a tall pane. The permission dialog, AskUserQuestion and the
MCP menu still refuse.
- **A Claude draft whose first line is blank** reads as a draft, so a send doesn't type into it or submit it.
- **A draft-aware paste to a Claude seat** is no longer read as edited when Claude swaps its footer for "paste again to
expand", so OpenRig no longer withholds Enter for that reason. It isn't confirmed to fix #14 (see Known gaps).
- **A send no longer answers a Pi dialog.** At an adopted Pi TUI's selector (a confirmation), an ordinary `rig send`
used to type and press Enter, choosing the highlighted answer; it now refuses with `target_needs_input` and types
nothing (#2015). #2026 extends this to Pi v1.4.0 extension input and editor dialogs, including help wrapped over up to
three rows in an 80-column pane. The audited deliberate-interaction route is unchanged.
- **Runner-managed Pi seats show and cancel an extension dialog** in the pane, so nothing is approved, and need
attention until their next agent run. On Pi and OMP seats, a second startup block joins the running turn instead of
failing with "Agent is already processing".
- **A send to a busy Codex seat** warns that the message joins the running turn and may redirect it, and names
`--wait-for-idle <seconds>` for separate work.
- **A seat with another pane in its tmux session** gets sends and wakes in its recorded pane instead of a refusal.
- **A wake refused at an approval prompt** retries once when the seat's attention clears, instead of waiting for the
stuck sweep.
- **A seat held at a choice prompt that no hook announces** (a usage-limit dialog, say) now escalates once the refusal
has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 seconds by default). Choice-prompt alerts
follow `queue.wake_human_rung`: under `explicit-only` they stay with the operator seat unless that seat is a person.
Permission-prompt alerts post as before.
- **The parked-owner reminder waits 5 minutes** after a seat starts waiting, so a seat that just handed work off or
asked a person isn't reminded at once; a seat at an approval or question prompt gets no grace. The reminder tells a
seat waiting on a person to hold the row (`rig queue block <id> --on <who> --wake-after <duration>`), not ask again.
- **A dead seat's stuck-sweep finding goes to someone who can act:** the operator agent (`agents.operator_session`)
when set, then the operator seat (`workspace.operator_seat_name`), then the row's creator, skipping any whose session
is confirmed missing. `workspace.operator_seat_name` still decides where approvals go.
- **Rows OpenRig creates itself** (stuck-sweep findings, workflow escalations, continuity batons, diagnoses run with
`--apply`) wake from OpenRig's own sender, with no reply line, instead of from the row's creator. When the sweep
closes its own finding, the actor is `stuck-sweep@system`.
- **A cross-host queue write that times out reads INDETERMINATE,** not failed, and the CLI waits 15 seconds for the
daemon's answer. When `rig queue create` gets no answer, it reads the row back once by its ID and reports an exact
match as created. It never sends the create twice.
- **Codex seats keep write access to their git metadata** after resume, fork and a restore without a pod, so they can
still commit, and to their own topology folder, so they keep `LEARNED.md` and their recap. `rig context recap-write`
falls back to the daemon when the seat's sandbox refuses the write.
- **Recap writes refuse a link** at `RECAP.md` or `recap-superseded/` with `recap_write_refused`, a layout that used to
work. Replace the link with a real file or folder.
- **`rig seat restart --keep-conversation` on a Codex seat** keeps a writable root whose path holds a space, proves the
resume in the `CODEX_HOME` the running Codex uses, and reads complete when a directory it was given has a space. When
the resume isn't proven, it says the pane restarted, not "nothing was restarted".
- **A Codex seat at Codex 0.164.0's sign-in chooser or API-key entry, or 0.162.1's chooser,** reports
`attention_required` with `login_required` instead of a readiness timeout. Nothing is typed into the login screen.
- **Resume recognition:** a resumed Codex whose footer shows a display name with a reasoning label (`GPT-6.1 Sol
medium`) or a non-`gpt` model reads as resumed, so `rig up --existing` doesn't stop. A Claude or Codex resume chooser
drawn high in a tall pane reads as a chooser, not as resumed.
- **Codex hooks:** when a Codex home has a `hooks.json`, OpenRig puts its activity hooks there at the next Codex seat
launch and moves its own old block out of `config.toml`. Your hooks, their order, trust and disables stay; your own
inline hooks stay in `config.toml`. Inline hook trust is keyed by the group's actual position.
- **A managed Claude restore reads its own files without a Read approval:** the seat's marker and restore map, the
generic or configured extra-instruction file, the restore skill files, restore maps under a configured
`preparation_root` and the packet files are granted at launch and resume, at both the named and the real path (so
macOS `/tmp` works).
- **After managed compaction,** a Claude seat's restore starts on the next poll instead of waiting for the screen to
change. `rig compact --state` and seat status show an owed restore stage and its last delivery result; repeated
failures open one queue item for the delegation owner.
- **A Claude startup refused late** (an expired login) no longer gets Enter on its next startup input: OpenRig checks
again before pressing it and reports attention. The text may stay in the input box; don't resend it.
- **A Claude seat with the model alias `haiku` and `plan` mode** gets a line in its launch, restore and `rig seat
status` results: Claude Code answers plan turns with Sonnet; set a full Haiku id with `rig seat set-model` to keep
Haiku. Nothing about the launch changes.
- **The model-divergence monitor** no longer flags a Claude seat pinned to an undated id that answers with its dated
snapshot, or one pinned to a Bedrock cross-Region profile id such as `global.anthropic.<model>`.
- **A pod seat that never launched** starts fresh from its saved launch recipe after a daemon restart, instead of
sitting at a shell. That starts the harness and can use provider usage. Seats materialized before 0.7.2 have no recipe
and report `attention_required`, keeping the shell.
- **`rig down` after a team's tmux server died** treats tmux's "no server running" as absence and records the seats
stopped, so `rig archive` and `rig up` go ahead. A team of plain terminal seats restores as `fully_restored`, and
`rig down` names the terminal sessions it stops.
- **A launch whose tmux made no session** fails with `session_not_created` or `session_unconfirmed` instead of reporting
the seat launched. The message says which directory must be writable.
- **Terminal tiles survive a seat's session replacement:** a tile waits up to 30 seconds for the same session and seat
identity, then reattaches. A fresh replacement's result says how to reopen the view. Input still queued when a
terminal client disconnects is dropped, not typed.
- **Reopening a Herdr view** with `--provider herdr` adds a workspace each time. From inside Herdr, `rig terminal open
<view> --window` focuses a matching one. The `openrig-herdr` skill now says so.
- **A service bundle install without Docker Compose v2** stops with `compose_missing` before writing the target or
creating the team. `rig bundle install --plan` warns instead.
- **`rig export`** copies the declared culture file beside the YAML for a local daemon, and names agent refs that leave
the folder and a rig still named `starter`.
- **`rig daemon start`:** a backlog of queue wakes no longer holds startup past the CLI's readiness wait. When the
database-conflict check can't tell (no `lsof`, a timeout, a listener that doesn't answer), startup warns that
ownership is unknown, under either `daemon.db_conflict` setting.
- **Proof and scope:** proof-source monitoring watches the missions root, not the whole workspace parent. `rig proof
add <absolute path>` finds a slice in another catalogued project. `rig scope mission create` writes
`metadata.name`; an older mission without it needs the line added before it compiles. `rig queue whoami` resolves a
project-tagged row against its catalogued project.
- **Slack:** a registered person's reply to a question parked on someone else reaches the agent as their own message
and leaves the question parked.
- **`rig context get reference/acp-agents.md`** now returns the guide `rig acp run --help` names.
- **TUI and dashboard:** the focused Table and Overview pages load health findings instead of staying `Not assessed`,
the activity stream keeps its connection state across pages, and the dashboard shows "boot not tracked" for a skipped
kernel boot.
- **The kernel operator's guidance** puts a project task on the chosen team's queue rather than doing it itself.
- **`rig auth switch` and `rig provider switch`'s printed undo** say a restored Codex login isn't verified and may need
a fresh sign-in.
## Output that changed shape
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
- **`rig capture` and the MCP capture tool** add `promptPane: { width, height, hint }` when a seat at a prompt has a
pane smaller than 80x24, and the CLI prints the resize hint on stderr.
- **`rig seat status`** shows the seat's runtime home and where it came from, any ignored home, a Claude token
reference and member tool rules. `--json` always has `permissions.account.advisories`, empty unless a note applies.
- **`rig stream show`** lists each reader's current action, flags `parallelWork`, and reads `readers: "unavailable"`
from an older daemon. The expired-cursor 410 says how `--since` resumes.
- **Slack posts start with their sender** (`From <seat>`, with ` on <machine>` when known), and a wake-rung escalation
reads `Waiting on <seat>: <summary>`. Rows from Slack read `<address> via Slack: …`.
- **A wake from a row OpenRig created** ends with "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
- **Stuck-sweep findings** name the rule that chose their recovery seat, and a stalled-after-claim age counts from the
stall, not from the claim.
- **The parked diagnosis** reports `activity.since`.
- **Cross-host queue writes:** a timed-out forward's 502 carries `outcome: "indeterminate"`, the CLI's headline reads
"queue request got no answer in time (routing to host '…')", and a read-back reports "The create committed after all:
row … (read back by its ID)".
- **`rig queue update --help` and the `closure_fields_not_admitted` refusal** say how to cancel a row.
- **`rig ps --nodes --json`** carries `login_required` for a Codex seat at sign-in; a restore result marks a plain
terminal seat `noConversation`.
- **A send to a seat mid-switch** is held at once: `outcome: retained`, `reason: runtime_switch_in_progress`.
- **New codes:** `permission_rules_unavailable` (a fork whose source rules can't be read), `occupant_retired_by_switch`
and `switch_not_committed` (the two processes of a runtime switch), `runtime_unchanged` and the `runtime_switch_*`
refusals, `session_not_created`, `session_unconfirmed`, `compose_missing`, `rex_outcome_unknown`,
`invalid_claude_token_selection`, and input checks on `rig stream act`.
- **Help:** 14 subcommands that had no summary have one, and `rig skill audit` ends by saying it doesn't judge whether
a refresh is safe, naming the read-only `rig skill loadout` check.
## What to stop doing
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
- **Don't pass `CLAUDE_CONFIG_DIR` or `CODEX_HOME` with `rig up --env`.** Declare the home in `env:`.
- **Don't rebuild a team to move one seat** to the other runtime or another login. Use `rig seat set-runtime` or
`rig provider switch --seat`.
- **Don't close and re-create to pass work on over MCP.** Use `rig_queue_handoff`.
- **Don't cancel with `--state canceled --closure-reason canceled`.** Use `--state done --closure-reason canceled`.
- **Don't take the first `qitem-…` from `rig queue create`'s merged output.** The request ID on stderr isn't a row:
read `qitemId` from stdout and check the exit code.
- **Don't retry a cross-host queue write that timed out.** Reconcile by its ID first.
- **Don't reopen a Herdr view with `--provider herdr` to get back to it.** Focus it, or use `--window` from inside Herdr.
- **Don't put one member's tool rules in a shared project's Claude settings.** Use `claude_permissions`.
## Known gaps in 0.7.1
## Known gaps in 0.7.2
- **On Linux, a seat now gets the daemon's runtime directory with a session bus address that can't connect** (#2036).
When the daemon has an absolute `XDG_RUNTIME_DIR`, a seat launched, restored or handed over gets it with
`DBUS_SESSION_BUS_ADDRESS=disabled:`, so its D-Bus clients don't find your session bus on their own. This is a
default, not isolation: `systemctl --user` reaches your user service manager from a seat. A shell startup file that
sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`; to use a session bus, set the address in the rig's
or member's `env:`. A Codex seat's stdio MCP server still needs `env_vars = ["XDG_RUNTIME_DIR"]` in Codex's
`config.toml` to see the variable. macOS, a daemon started without `XDG_RUNTIME_DIR` and
`terminal.inherit_tmux_environment` on are unchanged.
- **Per-member Claude tool rules** (#1914): member denies aren't claimed to hold in bypass mode (`full_bypass`).
Handover and restore carry the rules, but aren't proven to keep the same conversation. Checked natively: two seats in
one folder each enforce their own rules, a managed policy wins, and fresh, resume and fork launches enforce them.
- **Going back to 0.7.1:** like any version before #1914, it drops the stored member rules from launch, so set
equivalent native rules first (`docs/reference/rig-spec.md`, "Downgrade"). It also refuses a community preset that
sets `first_input`. No other way back to 0.7.1 has been tested.
- **Pi dialog recognition is a bounded screen check.** #2015 recognizes Pi's selector by its own chrome; with more than
six non-blank rows after its border, or under a widget that draws its own border, a send goes through as before.
#2026 adds Pi v1.4.0's extension input and editor dialogs; clipped or incomplete help, help wrapped over more than
three rows and custom extension screens keep their earlier reading, and a draft that copies a dialog's whole frame
reads as that dialog. Each was checked natively on its own cases (#2015 its confirmation dialog, #2026 the input and
editor dialogs on its own build), not on every layout. Check the pane before sending to an adopted Pi seat that may be
at a dialog. Neither
check was repeated on the final 0.7.2 build.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build. 0.7.2 treats the "paste again to expand" hint as part of
the composer, but the original failure didn't reproduce in its native check, so this isn't confirmed fixed; #14
remains open.
- **An idle Claude seat whose recent output quotes prompt text** can still read as at a prompt and refuse sends, as can
a seat with a draft, a mode bar OpenRig doesn't recognise or an unframed input box. Check the pane.
- **A Claude startup refusal that arrives after OpenRig's last check can still race Enter.** If a seat with an expired
login shows staged text in its input box, sign in, then clear or send that text yourself.
- **A managed Claude restore can still ask for a Read approval** when `policies.claude_compaction.message_file_path` is
relative, or for a packet file after a resume changed the conversation id. The session transcript isn't granted. A native
check on Claude 2.1.309 passed the Read grants with no approval prompt; a complete restore with no help isn't claimed.
- **`rig seat set-runtime`:** Claude launch additions such as an agent file aren't applied to a switched seat, startup
slash commands aren't run, and a person typing into the old pane of a draft-aware seat stops the switch (the current
occupant keeps the seat). A message sent during a switch is held and must be sent again.
- **A seat's own account:** a conversation saved under a seat's earlier home doesn't resume in its new one, and live
usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. `rig provider switch --seat`
moves a Codex seat on Linux only. Other seats on that Codex home keep the old login until their next token refresh,
then stop: run the restart command the switch printed for each.
- **Seat account and runtime moves weren't run with real accounts before this release** (#1895, #1937, #1972, #1980).
The tests use stubs; check a moved seat's account and conversation yourself after the first move.
- **A Codex seat whose queue-state folder or team workspace path holds a space** can still read as not resumed after an
ordinary resume or restore. An in-place restart is fixed.
- **Choice-prompt alerts go to a person one at a time per machine.** While the first one's seat stays blocked, later
choice alerts wait unposted; they're visible as rows.
- **Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting** (`needs-input`). Use
OpenRig's view or the Feed to find waiting seats.
- **Proof-source watching still recurses through a large missions tree,** and the reported Linux daemon stalls (#1813)
weren't reproduced to confirm that the narrower watch helps.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no automatic usage reading;** the reader
reports `not_subscription_login`. Read one on demand with `rig provider status --refresh --provider claude
--claude-token-file <path> --credential-label <label>`, which spends a model turn.
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
@@ -172,21 +294,11 @@ after an upgrade it can still hold the previous version's note.
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
--provider herdr`, which adds a new workspace; close the old one.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
+33 -8
View File
@@ -1,15 +1,38 @@
# Release Notes
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
This directory is the lightweight release history for OpenRig.
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
## Minimal Process
For each release:
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
2. Write the release's GitHub Release notes. No new file is added to this directory.
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
3. Run the **substance gate** at the exact release cut. The gate derives every
file under the shipped pack sources, verifies a hash-bound human judgment
and an individual disposition for each mechanical candidate, scans the
@@ -56,16 +79,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create the GitHub Release with the notes from step 2:
5. Create a GitHub Release using the same file:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
```
6. Publish the npm package.
6. Publish the npm package if that is part of the release flow.
## Guidance
@@ -78,3 +101,5 @@ For each release:
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+1 -1
View File
@@ -260,7 +260,7 @@ Operators can install `gstack` (45 skills) or `obra-superpowers`
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://192.0.2.10:17459/` walked end-to-end
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
+91 -287
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.2",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -473,13 +473,11 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -491,13 +489,11 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -509,13 +505,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -527,13 +521,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -545,13 +537,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -563,13 +553,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -581,13 +569,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -599,13 +585,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -617,13 +601,11 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -635,13 +617,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -653,13 +633,11 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -671,13 +649,11 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -689,13 +665,11 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -707,13 +681,11 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -725,13 +697,11 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -743,13 +713,11 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -761,13 +729,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -779,13 +745,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -797,13 +761,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -815,13 +777,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -833,13 +793,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -851,13 +809,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -869,13 +825,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -887,13 +841,11 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -905,13 +857,11 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -923,13 +873,11 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1085,60 +1033,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz",
"integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.8",
"jose": "^6.1.3",
"pkce-challenge": "^5.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
"integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/node": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.2.tgz",
"integrity": "sha512-IIcIqBpr6NCQQs5DgJTgvbSOAw+3iaR/uquqg+e0nXCdudfjPg/80x0TseUnQx14AUAtE4fsJCEpJKPyb6WPVA==",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.1.3",
"hono": "^4.11.4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@modelcontextprotocol/server": "^2.4.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1172,19 +1073,6 @@
}
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
"integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3120,20 +3008,20 @@
}
},
"node_modules/body-parser": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.1.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
@@ -3143,23 +3031,10 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -3394,9 +3269,9 @@
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3799,9 +3674,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3899,9 +3774,9 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -3918,15 +3793,15 @@
}
},
"node_modules/express": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.3.0",
"content-disposition": "^2.0.1",
"content-type": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
@@ -3942,14 +3817,14 @@
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.8",
"qs": "^6.16.0",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.1.0",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
@@ -3961,13 +3836,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -3979,19 +3853,6 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4393,9 +4254,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4685,16 +4546,12 @@
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
@@ -4835,32 +4692,12 @@
}
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
@@ -4925,19 +4762,15 @@
}
},
"node_modules/on-finished": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/once": {
@@ -4987,9 +4820,9 @@
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -5231,9 +5064,9 @@
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -5241,10 +5074,6 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/punycode": {
@@ -5258,13 +5087,12 @@
}
},
"node_modules/qs": {
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -5294,16 +5122,12 @@
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
@@ -5322,9 +5146,9 @@
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5761,14 +5585,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -5780,13 +5604,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
@@ -6234,34 +6058,17 @@
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/typescript": {
@@ -7224,12 +7031,12 @@
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
"zod": "^3.25 || ^4"
}
},
"node_modules/zustand": {
@@ -7262,15 +7069,13 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -7289,7 +7094,6 @@
"rig": "dist/bin-wrapper.js"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -7300,7 +7104,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.2",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7342,7 +7146,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.2",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.2",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -47,9 +47,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -64,7 +62,6 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+1 -5
View File
@@ -179,11 +179,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
if (turnOver || cancelRequestedAt) return;
cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId });
try {
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
} catch (error) {
console.error(`[rig acp] could not record the cancel request: ${String(error)}`);
}
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
});
// A detach settles the turn here even when the agent keeps its stdout open after stdin closes.
const detached = new Promise<never>((_, reject) => {
+24 -83
View File
@@ -3,9 +3,8 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
export function terminalAuthHeaders(): Record<string, string> {
const token = resolveTerminalToken();
@@ -147,15 +146,9 @@ interface DaemonRequestOptions {
interface DaemonClientOptions {
ownerProbe?: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
fetchImpl?: typeof fetch;
/** For a request allowed FETCH_HEADERS_TIMEOUT_MS or longer, which fetch would cut; default: fetchImpl when given, else
* node:http. */
longFetchImpl?: typeof fetch;
timeoutMs?: number;
/** The seat this client acts as, for a client built per authenticated caller; default: the process env. */
sessionName?: string;
/** With sessionName: the caller whose grant the MCP adapter checked, sent as X-OpenRig-Principal (JSON of its source,
* id and, for a grant-service grant, grant id and version; never a credential). */
principal?: { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
}
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
@@ -177,7 +170,6 @@ function localDaemonUrl(): string | undefined {
export class DaemonClient {
readonly baseUrl: string;
private fetchImpl: typeof fetch = fetch;
private longFetchImpl: typeof fetch = nodeHttpFetch;
private timeoutMs = 5_000;
private ownerProbe: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
/** Set by remoteDaemonClient; absent origin is labelled explicitly on remote requests. */
@@ -186,85 +178,36 @@ export class DaemonClient {
private identity?: Promise<Record<string, string>>;
private originUnknown = false;
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
/** Isolated terminal tools require this home's durable identity, even on loopback. */
async isLocalInstance(): Promise<boolean> {
if (this.remoteTarget) return false;
const origin = (await import("./local-origin.js")).readLocalOrigin();
return !!origin && await this.matchesInstanceIdentity(origin);
}
private async matchesInstanceIdentity(origin: string): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
return target === origin;
} catch { return false; }
finally { clearTimeout(timer); controller.abort(); }
}
private async identityHeaders(): Promise<Record<string, string>> {
const { headers, local } = await this.senderHeaders();
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
return { ...headers, "X-OpenRig-Principal": principal };
}
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
private async endpointIsThisHost(): Promise<boolean> {
if (this.remoteTarget) return false;
// Imported here, not at the top: it loads better-sqlite3 and yaml.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
return !!origin && (await this.endpointSelfHostId()) === origin;
}
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
private async endpointSelfHostId(): Promise<unknown> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
} catch { return undefined; }
finally { clearTimeout(timer); controller.abort(); }
}
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
* endpoint answered with this host's own instance id. */
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
const session = localHeaders[SENDER_IDENTITY_HEADER];
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
if (!session || session.split("@").length >= 3) return localHeaders;
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (!this.remoteTarget && !directTarget) return localHeaders;
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
if (!origin) {
this.originUnknown = true;
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
return { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
}
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality; unproved, the sender carries
// the known origin, never the endpoint's identity.
if (!this.remoteTarget && (await this.endpointSelfHostId()) === origin) return { headers: localHeaders, local: true };
return { headers: senderIdentityHeaders(origin, this.sessionName), local: false };
if (!this.remoteTarget) {
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
if (target === origin) return localHeaders;
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
finally { clearTimeout(timer); controller.abort(); }
}
return senderIdentityHeaders(origin, this.sessionName);
}
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
@@ -279,7 +222,7 @@ export class DaemonClient {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigUrl();
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -296,11 +239,9 @@ export class DaemonClient {
}
this.fetchImpl = options?.fetchImpl ?? fetch;
this.longFetchImpl = options?.longFetchImpl ?? options?.fetchImpl ?? nodeHttpFetch;
this.ownerProbe = options?.ownerProbe ?? ((url, timeoutMs) => assessLocalDaemonOwner(url, { fetchImpl: this.fetchImpl, timeoutMs }));
this.timeoutMs = options?.timeoutMs ?? 5_000;
this.sessionName = options?.sessionName;
this.principal = options?.principal;
}
/** Shared with raw CLI data readers. Constructor URLs alone do not establish explicit intent. */
@@ -385,7 +326,7 @@ export class DaemonClient {
let responseStatus: number | undefined;
try {
const response = await fetchWithTimeout(
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
+1 -1
View File
@@ -94,7 +94,7 @@ Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second C
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
Example:
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
rig acp run qitem-20261023-abc --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
+11 -17
View File
@@ -11,31 +11,26 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).replace(/\r?\n$/, "");
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string; tmuxSocketPath?: string } {
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
const tmuxPane = process.env["TMUX_PANE"];
if (!tmuxPane) {
return { attachmentType: "external_cli" };
}
try {
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
// Read the socket separately: tmux replaces control-character delimiters,
// and session names and socket paths may both contain literal spaces.
const tmuxSocketPath = tmuxExec(`tmux display-message -p -t ${shellQuote(resolvedPane!)} '#{socket_path}'`).replace(/\r?\n$/, "");
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane!,
...(tmuxSocketPath ? { tmuxSocketPath } : {}) };
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
if (tmuxSession && resolvedPane) {
return {
attachmentType: "tmux",
tmuxSession,
tmuxWindow: tmuxWindow || undefined,
tmuxPane: resolvedPane,
};
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
@@ -124,7 +119,6 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
body["tmuxSession"] = attachContext.tmuxSession;
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
body["tmuxPane"] = attachContext.tmuxPane;
if (attachContext.tmuxSocketPath) body["tmuxSocketPath"] = attachContext.tmuxSocketPath;
}
if (opts.displayName) body["displayName"] = opts.displayName;
+7 -73
View File
@@ -26,8 +26,6 @@ interface CaptureOpts {
host?: string;
json?: boolean;
historyPlusPane?: boolean;
readable?: boolean;
size?: string;
/** --lines was given on the command line (not the "20" default). */
linesGiven?: boolean;
/** Set when --lines N was given (and --history-plus-pane wasn't): the output is cut to the last N lines. */
@@ -46,30 +44,6 @@ function exactLineCount(opts: CaptureOpts, linesFromCli: boolean): number | unde
return n > 0 ? n : undefined;
}
/** How long a readable capture request may take: the daemon's 8 s for readable reads (CAPTURE_READABLE_BUDGET_MS, shared
* across a fan-out), its 1 s for input-box reads, and the client's usual 5 s for the rest. */
export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
/** `--readable` reads at 120x40; `--size WxH` at that size. Null when neither was given; an error for a bad --size. */
/** What happened to the window after a readable read, as far as OpenRig knows. */
export function readableRelease(release: string | undefined): string {
switch (release) {
case "given-back": return "its window was then given back its size and its own window-size";
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
default: return "its window's record had changed, so OpenRig left the window as it found it";
}
}
export function readableSize(opts: Pick<CaptureOpts, "readable" | "size">): { width: number; height: number } | null | { error: string } {
if (opts.size === undefined) return opts.readable ? { width: 120, height: 40 } : null;
const match = /^(\d{1,4})x(\d{1,4})$/.exec(opts.size);
const width = match ? Number(match[1]) : 0;
const height = match ? Number(match[2]) : 0;
if (!match || width < 1 || height < 1 || width > 1000 || height > 1000) return { error: `--size wants WIDTHxHEIGHT, each from 1 to 1000 (got ${JSON.stringify(opts.size)})` };
return { width, height };
}
/**
* The last `n` lines of a capture. The blank rows of the visible pane below its content are dropped first: on a
* short shell pane the bottom rows are empty, and the last N rows would otherwise be all blank.
@@ -118,14 +92,6 @@ function renderCapture(data: Record<string, unknown>): void {
// A prompt in a pane too small to show it: say so, with the resize command, rather than leave a reader guessing.
const pane = r["promptPane"] as { hint?: string } | undefined;
if (pane && typeof pane.hint === "string") console.error(`[rig capture: ${r["sessionName"]}: ${pane.hint}]`);
// --readable: the size it was read at, or why the window was left as it was.
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
if (readable?.resized === true) {
const after = readable.release === "given-back" && readable.sizeBack
? `its own window-size was given back, but ${readable.sizeBack}`
: readableRelease(readable.release);
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
};
// Multi-target result
@@ -164,8 +130,6 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Show the last N lines of the pane (without it: 20 history lines plus the whole visible pane)", "20")
.option("--history-plus-pane", "With --lines N, show N history lines plus the whole visible pane, as before --lines was exact")
.option("--readable", "Read a seat whose pane is smaller than 120x40 at that size, then give its window back")
.option("--size <WxH>", "With or without --readable: read at this size instead (for example 160x50)")
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
@@ -173,7 +137,6 @@ Examples:
rig capture dev-impl@my-rig
rig capture dev-impl@my-rig --lines 50
rig capture dev-impl@my-rig --lines 5 --history-plus-pane
rig capture dev-impl@my-rig --readable
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json
@@ -188,15 +151,8 @@ Supported notes:
suggestion regions on the visible screen (zero-based rows and columns), evidence and hint.
- When the captured screen is at a prompt and the seat's pane is smaller than 80x24, a
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
window, waits for the seat to redraw, captures, then puts the window back, both its size and
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
are. A stderr note says which; --json has it as readable.
cut off and gives the tmux resize-window command that makes it readable. --json has it as
promptPane: width, height and hint.
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
above them are left out, a "[rig capture: ...]" note on stderr says how many; --json reports
lines (returned), requestedLines and omittedLines. Trailing rows that are entirely whitespace
@@ -229,12 +185,6 @@ Supported notes:
const deps = getDeps();
opts.linesGiven = cmd.getOptionValueSource("lines") === "cli";
opts.exactLines = exactLineCount(opts, opts.linesGiven);
const size = readableSize(opts);
if (size && "error" in size) {
console.error(size.error);
process.exitCode = 1;
return;
}
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
@@ -265,18 +215,14 @@ Supported notes:
const client = deps.clientFactory(getDaemonUrl(status));
const lines = parseInt(opts.lines ?? "20", 10);
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: requestedLines };
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
body.inputBox = true; // the input box note; a daemon without it ignores the field
if (size) body.readable = size; // a daemon without it ignores the field and captures as before
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body,
{ headers: terminalAuthHeaders(), ...(size ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {}) });
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body, { headers: terminalAuthHeaders() });
if (res.status < 400) res.data = withExactLines(res.data, opts.exactLines);
if (opts.json) {
@@ -336,16 +282,8 @@ async function runCrossHostCapture(
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
// Only an explicit --lines is forwarded: the remote's own default stays the default.
// A value that parses as a positive count goes over as given (the remote decides);
// only a missing, zero, negative or non-numeric count sends the documented default.
if (opts.linesGiven || opts.historyPlusPane) {
const given = opts.lines ?? "20";
const parsed = parseInt(given, 10);
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
}
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
if (opts.historyPlusPane) argv.push("--history-plus-pane");
if (opts.readable) argv.push("--readable");
if (opts.size !== undefined) argv.push("--size", opts.size);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
@@ -383,17 +321,13 @@ async function runHttpHostCapture(
hint?: string,
): Promise<void> {
const lines = parseInt(opts.lines ?? "20", 10);
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: requestedLines };
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
body.inputBox = true;
const size = readableSize(opts);
if (size && !("error" in size)) body.readable = size;
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps,
body.readable ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {});
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
if (result.ok) result.data = withExactLines(result.data, opts.exactLines);
if (opts.json) {
+4 -23
View File
@@ -4,24 +4,7 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execFileSync } from "node:child_process";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
* as a newline or tab), so `-P -F` prints the name tmux used. */
export function chatroomWatchTmuxArgs(rig: string): string[] {
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
}
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
function tmuxArg(value: string): string {
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
}
import { execSync } from "node:child_process";
interface RigSummary {
id: string;
@@ -192,11 +175,9 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.tmux) {
const sessionName = `chatroom@${rig}`;
try {
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
console.log(`Started watch in tmux session: ${sessionName}`);
console.log(`Attach with: tmux attach -t ${sessionName}`);
} catch {
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
process.exitCode = 1;
+4 -7
View File
@@ -52,13 +52,10 @@ from --state, asking it to skip reading if it did not compact; a retry would com
An explicit retry still waits for the live pane to be idle; no automatic retry follows
cancellation. --skip-map skips only the map requirement for this one
manual request. Cancel an active preparation before retrying or using --skip-map.
Automatic preparation allows 25 minutes from prep delivery for the map. A completed
map stays armed for idle through the next retry interval (10 minutes by default),
without another preparation prompt; --state includes idleDeadlineAt. A completed
map that never reaches idle reports map_complete_seat_never_idle; an unfinished
map reports preparation_incomplete. Manual requests still share one 180-second budget.
Stopped attempts may retry automatically after the cooldown; cancellation does not.
Cancellation disarms future input; it cannot undo a command already submitted. Raw provider /compact and normal messages are unchanged.`);
Automatic preparation waits at most 25 minutes from prep delivery, using existing
polls. A stopped attempt does not restart automatically; rerun this command to
start a fresh attempt. Cancellation disarms future input; it cannot undo a command
already submitted. Raw provider /compact and normal messages are unchanged.`);
const getDepsF = (): CompactDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
+3 -7
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -96,10 +96,8 @@ Keys:
wake_retry_interval_seconds, wake_retry_cap, wake_unconfirmed_window_minutes,
wake_swap_grace_seconds (S01 wake-or-escalate ladder),
wake_human_rung (always | explicit-only — post agent-to-agent escalations to a person;
explicit-only posts rows addressed to a person; other agent work stays visible,
permission prompts still reach a person,
choice prompts follow this setting; unavailable human delivery stays a visible row;
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
under explicit-only, an ask that needs a person must be addressed to the person;
without a human registry, explicit-only posts everything, as always does)
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
@@ -108,7 +106,6 @@ Keys:
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
terminal.status_bar show the inner tmux status bar on launch (default off)
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
To keep more: rig config set terminal.history_limit 50000, then rig setup. A running tmux server keeps
its old limit until it restarts or you run tmux source-file ~/.tmux.conf; before tmux 3.7 that changes new panes only
@@ -196,7 +193,6 @@ Precedence: CLI flag > environment variable > config file > default`)
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
}
} catch (err) {
console.error((err as Error).message);
+5 -11
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
@@ -54,14 +54,6 @@ function existsOrThrow(path: string): boolean {
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
}
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
return openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
}
/**
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
@@ -71,7 +63,7 @@ export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { ho
async function realEmit(): Promise<CrashCartEmit> {
const cc = await import("@openrig/daemon/crash-cart");
const home = getOpenRigHome();
const openrigUrl = readOpenRigUrl();
const openrigUrl = process.env.OPENRIG_URL;
const probeClassified = (url: string) =>
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
const detectDeps = {
@@ -91,7 +83,9 @@ async function realEmit(): Promise<CrashCartEmit> {
? `alive (pid ${state.pid})`
: `dead (pid ${state.pid})`
: "no daemon.json";
const url = crashCartEvidenceUrl(openrigUrl, state);
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+2 -12
View File
@@ -264,7 +264,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
@@ -272,10 +272,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else if (result.kernelState === "skipped") {
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
console.error(skippedKernelMessage(result.detail));
process.exitCode = 1;
} else {
// Honest 3-part error per banked discipline.
console.error(
@@ -345,19 +341,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
console.log("Daemon PID is absent (stale state)");
break;
case "unverified":
// T2: a live start reservation names a launch in progress (listener not up yet).
if (status.startPending) {
const since = status.startPending.since ? ` since ${status.startPending.since}` : "";
console.log(`Daemon starting: launch reserved by pid ${status.startPending.pid}${since}; not listening yet`);
}
// 1ae863d2 — C3 semantics: we could NOT confirm up or down; never claim stopped.
if (status.siblingHint) {
console.log("Daemon state UNVERIFIED — the resolved OPENRIG_HOME has no daemon state, but a live daemon appears under a sibling home:");
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
console.log(` sibling: ${status.siblingHint.siblingHome}`);
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
} else if (!status.startPending) {
// A pending start's probe was refused, not inconclusive; its line above says what is known.
} else {
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
console.log(" Re-check with: rig daemon status · direct: curl the daemon /healthz");
}
-2
View File
@@ -18,7 +18,6 @@ import {
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
import { readRecordedTmuxServerArgs } from "../tmux-attachment.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
@@ -117,7 +116,6 @@ function realDestroyDeps(): DestroyDeps {
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
resolveTmuxServerArgs: readRecordedTmuxServerArgs,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
+9 -97
View File
@@ -13,12 +13,11 @@ import {
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { parse as parseYaml } from "yaml";
import { compareSpecToLive, topologyFromRigSpec, topologyFromLiveLogicalIds } from "@openrig/daemon/spec-conformance";
import { classifyNodeVersion } from "../node-support.js";
import { checkProviderReadiness } from "../provider-auth.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
interface DoctorCheck {
name: string;
@@ -32,8 +31,7 @@ export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
/** `timeoutMs` bounds one command; without it a command runs to completion, as before. */
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
exec: (cmd: string) => string;
checkPort: (port: number, host: string) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
@@ -41,12 +39,7 @@ export interface DoctorDeps {
env?: NodeJS.ProcessEnv;
mkdirp?: (path: string) => void;
checkWritable?: (path: string) => void;
fetch?: (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
/** The caller's effective user id, compared with the one the port's daemon reports. Absent: this process's own;
* null: this platform has none. */
callerUid?: number | null;
/** How long the port's /healthz body may take to arrive (default 2000 ms); after that its owner is unknown. */
ownerReportTimeoutMs?: number;
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
/**
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
*
@@ -63,71 +56,6 @@ export interface DoctorDeps {
const DEFAULT_PORT = 7433;
const CMUX_SOCKET_CONTROL_FIX = 'In cmux Settings > Automation, select "Automation mode" for Socket Control Mode, or set automation.socketControlMode to "automation" in ~/.config/cmux/cmux.json (older versions use ~/.config/cmux/settings.json; current cmux reads that legacy file only for keys absent from cmux.json). cmux reloads saved settings automatically; rerun `rig doctor` to re-probe without restarting the daemon.';
/** The port's OpenRig daemon, judged by the OS user it reports. /healthz's pid and euid are the daemon's own report,
* not authentication, and every line says so. Another user's daemon warns, since a share set up through config alone
* looks the same and must keep working; it passes when OPENRIG_URL selects this endpoint on purpose. An owner that can't
* be compared, or a body that doesn't arrive in time, warns too. Nothing is stopped or refused here. */
async function listenerOwnerCheck(where: string, base: string, res: { json?: () => Promise<unknown> }, release: () => void,
deps: DoctorDeps): Promise<DoctorCheck> {
const env = deps.env ?? process.env;
const at = `Port ${where} (${base}, OpenRig home ${getOpenRigHome(env)})`;
const callerUid = deps.callerUid === undefined ? process.geteuid?.() : deps.callerUid ?? undefined;
const boundMs = deps.ownerReportTimeoutMs ?? 2000;
let reported: { pid?: unknown; euid?: unknown } = {};
let late = false;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const read = res.json ? res.json() : Promise.resolve(null);
// Releasing the request after the bound rejects the abandoned read; that's expected, not an error.
read.catch(() => {});
const body: unknown = await Promise.race([
read,
new Promise<null>(resolve => { timer = setTimeout(() => { late = true; resolve(null); }, boundMs); }),
]);
if (body && typeof body === "object") reported = body as { pid?: unknown; euid?: unknown };
} catch { /* no readable body: the owner stays unknown */ }
finally { clearTimeout(timer); release(); }
const pid = Number.isSafeInteger(reported.pid) && (reported.pid as number) > 0 ? `, pid ${reported.pid as number}` : "";
const euid = Number.isSafeInteger(reported.euid) && (reported.euid as number) >= 0 ? reported.euid as number : undefined;
const ownDaemon = "To run your own, give this OpenRig home a free port and start its daemon there: rig config set daemon.port <free-port>, then rig daemon start.";
if (euid === undefined || callerUid === undefined) {
return {
name: "port", status: "warn",
message: `${at} is in use by an OpenRig daemon whose OS user is unknown.`,
reason: late
? `Its /healthz report didn't finish within ${boundMs / 1000} s, so doctor can't tell whether it's yours or another user's on this machine.`
: euid === undefined
? "It doesn't report an effective user id, so doctor can't tell whether it's yours or another user's on this machine."
: "This platform has no effective user id to compare, so doctor can't tell whether it's yours.",
fix: `Check that it's yours with rig daemon status. ${ownDaemon}`,
};
}
const reports = `the endpoint reports euid ${euid}${pid}`;
if (euid === callerUid) return { name: "port", status: "pass", message: `${at} is in use by your OpenRig daemon: ${reports}, the same as yours.` };
const users = `another OS user: ${reports}; your euid is ${callerUid}`;
if (sameEndpoint(readOpenRigUrl(env), base)) {
return { name: "port", status: "pass", message: `${at} is the endpoint OPENRIG_URL selects, served by an OpenRig daemon of ${users}.` };
}
return {
name: "port", status: "warn",
message: `${at} is in use by an OpenRig daemon of ${users}.`,
reason: `It isn't this OpenRig home's daemon, unless that user shares it with you: a command that reaches ${base} acts in that user's OpenRig. The pid and euid are the daemon's own report on /healthz, a self-report rather than authentication.`,
fix: `${ownDaemon} To use a shared daemon on purpose, set OPENRIG_URL to its address.`,
};
}
/** Whether OPENRIG_URL names this endpoint: the same scheme, host and port, with localhost read as 127.0.0.1. */
function sameEndpoint(url: string | undefined, base: string): boolean {
if (!url) return false;
try {
const [a, b] = [new URL(url), new URL(base)];
const host = (u: URL) => u.hostname === "localhost" ? "127.0.0.1" : u.hostname;
return a.protocol === b.protocol && a.port === b.port && host(a) === host(b);
} catch {
return url === base;
}
}
function defaultCheckPort(port: number, host: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
@@ -200,16 +128,6 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
});
} else if (tmuxProbe.available && tmuxProbe.version) {
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
if (capture.status === "fail") {
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
checks.push({ name: "tmux_capture", status: "fail", message: failure.message, reason: failure.reason, fix: failure.fix });
} else {
checks.push(capture.status === "pass"
? { name: "tmux_capture", status: "pass", message: "tmux can capture a pane (checked on a throwaway server)." }
: { name: "tmux_capture", status: "skipped", message: `Couldn't start a throwaway tmux server to check capture-pane: ${capture.detail}` });
}
if (platform === "darwin") {
const mouseMode = readTmuxMouseMode(deps);
if (mouseMode === "on") {
@@ -305,13 +223,13 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
if (available) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} available.` };
}
// Port in use — check if it's an OpenRig daemon via healthz, and whose.
const owner = new AbortController();
// Port in use — check if it's our daemon via healthz
try {
const res = await fetchFn(`${daemonBase}/healthz`, { signal: owner.signal });
if (res.ok) return await listenerOwnerCheck(`${daemonHost}:${daemonPort}`, daemonBase, res, () => owner.abort(), deps);
const res = await fetchFn(`${daemonBase}/healthz`);
if (res.ok) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} in use by OpenRig daemon.` };
}
} catch { /* not our daemon */ }
finally { owner.abort(); }
return {
name: "port",
status: "fail",
@@ -466,12 +384,6 @@ function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): Do
};
}
/** The standalone doctor's command runner. A command with `timeoutMs` is bounded (the tmux capture probe passes one); others
* run to completion, as before. */
export function doctorExec(c: string, opts?: { timeoutMs?: number }): string {
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}) });
}
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
try {
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
@@ -515,7 +427,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
exists: existsSync,
baseDir: import.meta.dirname,
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
exec: doctorExec,
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
-4
View File
@@ -90,7 +90,6 @@ interface TeardownResult {
keptGuidance?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
}
interface DownAgentEntry {
@@ -299,9 +298,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
if (result.leftRunning && result.leftRunning.length > 0) {
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
}
if (result.paneOnly && result.paneOnly.length > 0) {
console.log(`Pane stopped: ${result.paneOnly.join(", ")}. A community harness's own processes may keep running after its pane; see its preset's limits.`);
}
// Exit code: errors first, then deleted, then alreadyStopped
if (result.errors.length > 0) {
+6 -11
View File
@@ -8,7 +8,7 @@ import type { StatusDeps } from "./status.js";
interface EdgeEnd {
logicalId: string;
nodeId: string | null;
nodeId: string;
}
interface EdgeSuccess {
@@ -39,8 +39,7 @@ function report(result: EdgeOpResult, opts: { json?: boolean }): void {
switch (result.outcome) {
case "would_add":
console.log(`plan add ${line} in rig ${result.rigId}`);
console.log(` from node ${edge.from.nodeId}, to ${edge.to.nodeId === null
? `${edge.to.logicalId} (resolved when used)` : `node ${edge.to.nodeId}`}`);
console.log(` from node ${edge.from.nodeId}, to node ${edge.to.nodeId}`);
console.log(" nothing written (--plan)");
break;
case "added":
@@ -125,10 +124,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
cmd
.command("add")
.description("Ensure one typed edge; an existing edge is unchanged")
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
.argument("<rig>", "Target rig name or ID")
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
.argument("[from]", "Source seat logical ID, such as orch.lead")
.argument("[to]", "Target seat logical ID, such as review.reviewer")
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
.option("--plan", "Validate the request and show the change without writing")
.option("--json", "JSON output for agents")
@@ -136,12 +135,8 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
Examples:
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
Use existing, fully qualified logical IDs in <rig> for both same-pod and
cross-pod edges. For escalates_to only, the target may instead be a canonical
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
need not exist yet. Cross-host and synthetic destinations are unsupported.
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
Run it once per edge. A repeat reports the existing edge and its ID, and writes
nothing. No seats or sessions are changed.`)
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
+1 -1
View File
@@ -8,7 +8,7 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig services; for execution placement use rig environment")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.addHelpText("after", `
Examples:
rig env status secrets-manager
-170
View File
@@ -1,170 +0,0 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { Command } from "commander";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { DaemonClient } from "../client.js";
import { getDaemonPath, getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { addGrant, grantFor, mcpTokenFilePath, readGrants } from "../mcp-http.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface EnvironmentCommandDeps extends StatusDeps { home?: string; assets?: string }
interface WorkConnection { version: 1; mcpUrl: string; seat: string; rig: string; grantName: string; artifactRoot: string; token: string }
const managedId = (id: string): string => {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(id)) throw new Error("A short binding identifier is required");
return id;
};
function preserveWrite(file: string, data: string | Buffer): void {
mkdirSync(resolve(file, ".."), { recursive: true, mode: 0o700 });
if (existsSync(file) || (() => { try { return lstatSync(file).isSymbolicLink(); } catch { return false; } })()) {
if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile() || !readFileSync(file).equals(Buffer.from(data)))
throw new Error(`An existing file is preserved: ${file}`);
return;
}
writeFileSync(file, data, { flag: "wx", mode: 0o600 });
}
function connectionRef(home: string, bindingId: string): string { return join(home, "environment-secrets", managedId(bindingId), "work.json"); }
function readConnection(file: string): WorkConnection {
const stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384 || (stat.mode & 0o077)) throw new Error("The managed work connection must be a private regular file");
const value = JSON.parse(readFileSync(file, "utf8")) as WorkConnection;
if (value.version !== 1 || typeof value.token !== "string" || !value.token || typeof value.mcpUrl !== "string") throw new Error("The work connection is invalid");
return value;
}
async function prepareWork(home: string, input: { bindingId: string; seat: string; rig: string; mcpUrl: string; artifactRoot: string }): Promise<string> {
const url = new URL(input.mcpUrl);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.pathname.endsWith("/mcp"))
throw new Error("The work endpoint must be an HTTP(S) MCP URL without embedded credentials");
if (!/^[^\s@]+@[^\s@]+$/.test(input.seat) || !input.rig) throw new Error("A canonical seat and its rig are required");
const file = connectionRef(home, input.bindingId), grantName = `environment-${input.bindingId}`;
if (existsSync(file)) {
const prior = readConnection(file), grants = readGrants(mcpTokenFilePath(home)) ?? [];
const grant = grantFor(grants, prior.token);
if (!grant || grant.name !== grantName || prior.seat !== input.seat || prior.rig !== input.rig || prior.mcpUrl !== url.toString()
|| prior.artifactRoot !== resolve(input.artifactRoot) || grant.seat !== input.seat || grant.owner || grant.workspace
|| grant.rigs.length !== 1 || grant.rigs[0] !== input.rig || grant.roots.length !== 0) throw new Error("The existing managed work connection changed or was revoked; preserve it and reconcile before setup");
return file;
}
const token = await addGrant(mcpTokenFilePath(home), { name: grantName, seat: input.seat, rigs: [input.rig], roots: [], workspace: false, owner: false });
const data: WorkConnection = { version: 1, mcpUrl: url.toString(), seat: input.seat, rig: input.rig, grantName, artifactRoot: resolve(input.artifactRoot), token };
preserveWrite(file, JSON.stringify(data) + "\n");
return file;
}
export function environmentCommand(override?: EnvironmentCommandDeps): Command {
const cmd = new Command("environment").description("Explicit OpenShell execution placement and verified artifact return; not rig env (services)");
const deps = (): EnvironmentCommandDeps => override ?? { lifecycleDeps: realDeps(), clientFactory: url => new DaemonClient(url) };
const client = async () => { const d = deps(), status = await getDaemonStatus(d.lifecycleDeps); if (!daemonStatusGuard(status)) throw new Error("A reachable daemon is required"); return d.clientFactory(getDaemonUrl(status)); };
const home = () => deps().home ?? getOpenRigHome();
const report = (value: unknown) => console.log(JSON.stringify(value, null, 2));
const action = (fn: (...args: any[]) => Promise<void>) => async (...args: any[]) => { try { await fn(...args); } catch (error) { console.error((error as Error).message); process.exitCode = 1; } };
const checked = <T extends Record<string, any>>(res: { status: number; data: T }): T => { if (res.status >= 400) throw new Error(res.data?.error ?? `Environment request failed (${res.status})`); return res.data; };
cmd.command("setup").description("Inspect the pinned vendor CLI and install the maintained example without overwriting another preset")
.requiredOption("--directory <path>", "New or matching example directory")
.option("--binding <id>", "Prepare this binding's normal scoped MCP work connection")
.option("--seat <address>").option("--rig <name>").option("--mcp-url <url>", "Existing authenticated MCP HTTP listener, as reached by the guest")
.option("--guest-base-url <origin>", "Guest-only activity listener origin (not the main daemon API); required with work authority")
.option("--artifact-root <path>", "Host returned-artifact root, bound to this environment")
.option("--json", "JSON receipt; never credential values")
.action(action(async (opts: { directory: string; binding?: string; seat?: string; rig?: string; mcpUrl?: string; guestBaseUrl?: string; artifactRoot?: string }) => {
const binary = await promisify(execFile)("openshell", ["--version"], { timeout: 10_000, maxBuffer: 4096 });
if (!/\b0\.1\.6\b/.test(binary.stdout)) throw new Error("This reference requires OpenShell 0.1.6; no compute was prepared");
const root = resolve(opts.directory), assets = deps().assets ?? join(getDaemonPath(), "assets", "openshell");
const names = ["guest.py", "Dockerfile", "policy.yaml", "rig.yaml", "agents/worker/agent.yaml", "agents/worker/role.md", "agents/coordinator/agent.yaml", "agents/coordinator/role.md", "README.md", "driver-v1.schema.json", "claude-code-provider.yaml"];
for (const name of names) {
let data = readFileSync(join(assets, name));
if (name === "policy.yaml" && opts.mcpUrl && opts.guestBaseUrl) {
const mcp = new URL(opts.mcpUrl), activity = new URL(opts.guestBaseUrl);
if (![mcp, activity].every(url => ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash)
|| activity.pathname !== "/") throw new Error("Explicit guest endpoints without embedded credentials are required");
const mainPort = Number(process.env.OPENRIG_PORT ?? "7433");
const ports = [mcp, activity].map(url => Number(url.port || (url.protocol === "https:" ? 443 : 80)));
if (ports.includes(mainPort) || ports[0] === ports[1]) throw new Error("Select separate MCP and guest-only activity ports, never the daemon main port");
const policy = parseYaml(data.toString("utf8"));
for (const [name, url] of [["openrig_mcp", mcp], ["openrig_activity", activity]] as const) {
policy.network_policies[name].endpoints[0].host = url.hostname;
policy.network_policies[name].endpoints[0].port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
}
data = Buffer.from(stringifyYaml(policy));
}
preserveWrite(join(root, name), data);
}
preserveWrite(join(home(), "community-harnesses", "openshell-claude.yaml"), readFileSync(join(assets, "preset.yaml")));
let ref: string | undefined;
if (opts.binding || opts.seat || opts.rig || opts.mcpUrl) {
if (!opts.binding || !opts.seat || !opts.rig || !opts.mcpUrl || !opts.guestBaseUrl) throw new Error("Preparing work authority needs --binding, --seat, --rig, --mcp-url and --guest-base-url together");
ref = await prepareWork(home(), { bindingId: opts.binding, seat: opts.seat, rig: opts.rig, mcpUrl: opts.mcpUrl, artifactRoot: resolve(opts.artifactRoot ?? join(root, "artifacts")) });
}
report({ version: 1, driver: "openshell", vendorVersion: "0.1.6", directory: root,
...(ref ? { workConnectionRef: ref } : {}), effect: "reference and preset installed; no environment or native process created" });
}));
for (const verb of ["create", "attach"] as const) {
const sub = cmd.command(verb).argument("<binding>").description(verb === "create" ? "Journal intent, then explicitly create one owned sandbox; retries reconcile the same operation" : "Verify and bind an exact existing resource as an attached consumer")
.requiredOption("--seat <address>").option("--node <id>", "Existing node ID; otherwise claimed by the exact named seat on its first launch")
.requiredOption("--gateway <ref>").requiredOption("--workspace <ref>").requiredOption("--name <name>")
.requiredOption("--image <digest>").requiredOption("--provider <ref>").requiredOption("--policy <path>")
.requiredOption("--guest-base-url <origin>", "Guest-reachable guest-only activity listener origin (not the main daemon API)")
.option("--operation <id>", "Stable effect identity (default verb plus binding ID)").option("--json");
if (verb === "attach") sub.requiredOption("--resource-id <id>", "Immutable live resource ID").option("--fingerprint <value>", "Expected host-key fingerprint");
sub.action(action(async (binding: string, opts: Record<string, string>) => {
managedId(binding); const ref = connectionRef(home(), binding), work = readConnection(ref);
if (work.seat !== opts.seat) throw new Error("The managed work connection belongs to another seat; no provider operation was requested");
const body = { version: 1, bindingId: binding, operationId: opts.operation ?? `${verb}-${binding}`, nodeId: opts.node, seat: opts.seat,
baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: opts.guestBaseUrl, coordinationRef: ref, artifactRoot: work.artifactRoot,
placement: { gateway: opts.gateway, workspace: opts.workspace, name: opts.name, image: opts.image, provider: opts.provider, policy: realpathSync(opts.policy!) },
...(verb === "attach" ? { resourceId: opts.resourceId, fingerprint: opts.fingerprint } : {}) };
const res = await (await client()).post<Record<string, any>>(`/api/environments/${verb}`, body, { timeoutMs: 120_000 });
report({ ...checked(res), operationId: body.operationId, effect: res.status === 202 ? "unconfirmed; reconcile this same operation" : "binding-confirmed; native launch not requested" });
}));
}
cmd.command("inspect").description("Read stored binding receipts or inspect the exact live resource").argument("<binding>").option("--live", "Observe the exact provider resource; unavailable is not absence").option("--json")
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
.action(action(async (id: string, opts: { generation: string }) => {
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
await new Promise<void>((resolveExit, reject) => {
const child = spawn("openshell", args, { stdio: "inherit" });
child.once("error", reject); child.once("exit", code => code === 0 ? resolveExit() : reject(new Error(`The environment viewer exited ${code}; native and compute state require inspection`)));
});
}));
cmd.command("export").argument("<binding>").requiredOption("--request <path>", "Versioned artifact request JSON with operation/task/generation/path/hash/bytes/schema/context")
.option("--json").description("Transfer actual bytes through the existing authenticated MCP work route, never an activity token")
.action(action(async (binding: string, opts: { request: string }) => {
const work = readConnection(connectionRef(home(), binding)), request = JSON.parse(readFileSync(opts.request, "utf8"));
const transport = new StreamableHTTPClientTransport(new URL(work.mcpUrl), { requestInit: { headers: { Authorization: `Bearer ${work.token}` } } });
const mcp = new Client({ name: "openrig-environment-export", version: "1" });
try {
await mcp.connect(transport);
const result = await mcp.callTool({ name: "rig_environment_export", arguments: { ...request, bindingId: binding } });
if (result.isError) throw new Error((result.content as Array<{ text?: string }>).map(c => c.text ?? "").join("\n"));
report(result.structuredContent ?? result.content);
} finally { await mcp.close(); }
}));
cmd.command("checkpoint").argument("<binding>").requiredOption("--generation <id>", "Selected current execution generation")
.requiredOption("--operation <id>", "Stable private checkpoint identity").option("--json")
.description("Capture exact private native history and verified prior artifact references while the provider remains accessible")
.action(action(async (id: string, opts: { generation: string; operation: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/checkpoint`, { operationId: opts.operation, generation: opts.generation }, { timeoutMs: 120_000 })))));
cmd.command("recreate").argument("<binding>").requiredOption("--request <path>", "JSON with operationId, predecessor generation, checkpointId, exact resumeType/resumeToken and same-host placement")
.option("--json").description("Explicitly create successor compute only after exact predecessor cleanup and retained state verification; native launch remains normal rig restore")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/recreate`, JSON.parse(readFileSync(opts.request, "utf8")), { timeoutMs: 120_000 })))));
cmd.command("completion").argument("<binding>").requiredOption("--request <path>", "JSON selecting operationId, taskId, generation, exportOperationId and stop-native or stop-compute")
.option("--json").description("Select finite completion for one task, generation and required verified export; persistent defaults stay unchanged")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8")))))));
cmd.command("effect").argument("<binding>").requiredOption("--effect <name>", "close-viewer, stop-native, stop-compute or delete-owned")
.option("--generation <id>", "Selected execution generation; omit only for delete-owned before any generation exists")
.option("--resource-id <id>", "Exact immutable compute ID; required for delete-owned without a generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--engine-socket <path>", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json")
.description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved")
.action(action(async (id: string, opts: { effect: string; generation?: string; resourceId?: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation ?? null, ...(opts.resourceId ? { resourceId: opts.resourceId } : {}), operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Run from your managed seat, or set OPENRIG_SESSION_NAME to the acting member@rig in a plain shell for existing transport attribution (not authentication). --guest-base-url names the separate guest-only activity listener, reachable from the guest; never the main API. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
return cmd;
}
+7 -23
View File
@@ -13,10 +13,6 @@ export interface ExportDeps extends StatusDeps {
exists?: (path: string) => boolean;
}
interface ExportedStartupBlock {
files?: Array<{ path?: string; required?: boolean }>;
}
/** Copy only the declared local culture, preserving any recipient file. */
function copyCulture(file: string, root: string, outDir: string): void {
if (nodePath.isAbsolute(file) || file.split(/[\\/]/).includes("..")) throw new Error("culture path must stay inside the rig folder");
@@ -69,10 +65,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
} else {
deps.writeFile(opts.output, res.data);
console.log(`Exported to ${opts.output}`);
for (const line of String(res.data).split("\n")) {
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
}
for (const line of String(res.data).split("\n")) if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
// Check for referenced culture_file and startup files resolving from the output directory
const checkExists = deps.exists ?? ((p) => fs.existsSync(p));
@@ -94,11 +87,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
if (parsed["name"] === "starter") {
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
}
const pods = parsed["pods"] as Array<{
id?: string;
startup?: ExportedStartupBlock;
members?: Array<{ id?: string; agent_ref?: string; startup?: ExportedStartupBlock }>;
}> | undefined;
const pods = parsed["pods"] as Array<{ id?: string; members?: Array<{ id?: string; agent_ref?: string }> }> | undefined;
for (const pod of Array.isArray(pods) ? pods : []) {
for (const member of Array.isArray(pod.members) ? pod.members : []) {
const local = typeof member.agent_ref === "string" && member.agent_ref.startsWith("local:") ? nodePath.normalize(member.agent_ref.slice(6)) : null;
@@ -124,16 +113,11 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
filesToCheck.push(parsed["culture_file"]);
}
const startupBlocks = [
parsed["startup"] as ExportedStartupBlock | undefined,
...(Array.isArray(pods) ? pods.flatMap((pod) => [pod.startup, ...(pod.members ?? []).map((member) => member.startup)]) : []),
];
for (const startup of startupBlocks) {
if (startup && Array.isArray(startup.files)) {
for (const f of startup.files) {
if (typeof f?.path === "string" && f.required !== false && !filesToCheck.includes(f.path)) {
filesToCheck.push(f.path);
}
const startup = parsed["startup"] as { files?: Array<{ path?: string; required?: boolean }> } | undefined;
if (startup && Array.isArray(startup.files)) {
for (const f of startup.files) {
if (typeof f?.path === "string" && f.required !== false && !filesToCheck.includes(f.path)) {
filesToCheck.push(f.path);
}
}
}
+28 -219
View File
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
// The human registry is home-state owned by the daemon; the verb LAZY-imports the narrow
@@ -183,30 +182,32 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
.option("--reason <reason>", "Reason recorded with connector binding changes", "register human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.option("--organization <id>", "The person's organization (create it first with rig gateway org set)")
.option("--group <id>", "A group the person belongs to (repeatable; create it first with rig gateway group set)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
// LAZY import the narrow daemon surface at invocation (dep rail 2).
const registry = await import("@openrig/daemon/gateway-human-registry");
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
addHumanFragment: typeof AddHumanFragment;
parseBindingSpec: (spec: string) => { ok: true; binding: Record<string, unknown> } | { ok: false; error: string };
};
// Several people may be registered. Their organization, groups, policy and the default go in the participant side
// file, never the fragment, so an older daemon still loads every person after a rollback.
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const placed = opts.organization !== undefined || opts.group !== undefined || policy.value !== undefined;
if (placed) {
// Check the organization and groups exist before writing anything, so a typo doesn't leave a half-placed person.
const side = registry.readParticipantsFile();
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
if (opts.organization && !side.file.organizations[opts.organization]) { console.error(`refused: no organization "${opts.organization}" — create it with rig gateway org set ${opts.organization} --name …`); process.exitCode = 1; return; }
const unknownGroup = (opts.group ?? []).find((group) => !side.file.groups[group]);
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
// Fix-r1 F1 (R2 blocking, A1/R5): the add VERB is the single-human boundary. A second
// DISTINCT human is refused BEFORE any parse or write — the several-fragment state A1
// describes as hand-authored must never be produced by the product's own surface.
// Fail closed: an unverifiable registry refuses too (never add into an unknown state).
const existing = registry.listHumans();
if (!existing.ok) {
console.error(`refused: cannot verify the single-human boundary — ${existing.error}`);
process.exitCode = 1;
return;
}
if (existing.humans.length > 0 && !existing.humans.some((h) => h.entityId === entityId)) {
const ids = existing.humans.map((h) => h.entityId).join(", ");
console.error(
`refused: a human is already configured (${ids}). OpenRig supports one configured human, so \`rig gateway human add\` manages one; ` +
`to register someone else, remove the existing one first (\`rig gateway human remove <entityId>\`). Fragments hand-authored under ${registry.humansDir()} ` +
`are displayed as found, with an advisory, but no command manages several.`,
);
process.exitCode = 1;
return;
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
@@ -235,19 +236,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
return { value: res, after: { digest }, effect: before.ok && registry.channelStateDigest(before.record.connectorBindings.map(({ inboundResolvable: _derived, ...binding }) => binding)) === digest ? "no-op" : "applied" };
},
});
// The person is registered; their placement and the default are a second, separate write.
const followUps: Record<string, unknown> = {};
if (placed) {
const res = registry.setPersonPlacement(entityId, { organization: opts.organization, groups: opts.group, policy: policy.value });
followUps.placement = res.ok ? { path: res.path } : { error: res.error };
}
if (opts.default) {
const res = registry.setDefaultPerson(entityId);
followUps.default = res.ok ? { path: res.path } : { error: res.error };
}
const failed = Object.values(followUps).some((value) => "error" in (value as object));
console.log(JSON.stringify({ ok: !failed, entityId, path: result.value.path, receipt: result.receipt, ...followUps }));
if (failed) { console.error(`the person was registered, but part of their placement wasn't saved; fix it with rig gateway human set ${entityId} … or rig gateway default ${entityId}`); process.exitCode = 1; }
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
})
.addHelpText("after", `
@@ -270,7 +259,7 @@ Example:
human
.command("list")
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
@@ -281,7 +270,7 @@ Example:
return { ...record, deliveryReadiness: await humanReadiness(record.entityId),
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
}));
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
if (opts.json) { console.log(JSON.stringify({ ok: true, humans, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
@@ -289,6 +278,7 @@ Example:
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
}
if (res.advisory) console.log(`advisory: ${res.advisory}`);
});
const quiet = human.command("quiet").description("Quiet hours: while on, routine asks to this person are held, not posted; urgent ones (tagged escalation) still reach them. Held asks post once, each on its own, when quiet ends");
@@ -530,22 +520,13 @@ Examples:
.description("Show the effective record: fragment values + which defaults filled the rest, with provenance")
.option("--json", "Complete record as JSON")
.action(async (entityId: string, opts: { json?: boolean }) => {
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
const { showHuman } = await import("@openrig/daemon/gateway-human-registry");
const res = showHuman(entityId);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
// The person's organization, groups, policy and whether they're the default come from the participant side file.
const view = participantsView();
const placement = view.ok ? view.participants.find((p) => p.id === entityId) : undefined;
const participant = placement
? { organization: placement.organization, groups: placement.groups, default: placement.default, policy: placement.policy }
: { unavailable: view.ok ? "not found" : view.error };
const sideFileError = view.ok ? view.sideFile.error : undefined;
const r = { ...res.record, participant, ...(sideFileError ? { participantFileError: sideFileError } : {}), deliveryReadiness: await humanReadiness(entityId) };
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
if (opts.json) { console.log(JSON.stringify({ ok: true, record: r })); return; }
console.log(`${r.entityId} (${r.address}) — "${r.displayName}"`);
console.log(` fragment: ${r.fragmentPath}`);
if (placement) for (const line of describePlacement(placement)) console.log(line);
if (sideFileError) console.log(` participant file: ${sideFileError}`);
console.log(` delivery-class: ${r.prefs.deliveryClass.value} (${r.prefs.deliveryClass.source})`);
console.log(` away: ${r.prefs.away.value} (${r.prefs.away.source})`);
console.log(` slack-subscription: ${r.slackSubscription.value} (${r.slackSubscription.source})`);
@@ -558,26 +539,11 @@ Examples:
human
.command("set <entityId> <field> <value>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>")
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (field === "organization" || field === "groups" || field === "policy") {
const clear = value === "unset";
let input: Record<string, unknown>;
if (field === "organization") input = { organization: clear ? null : value };
else if (field === "groups") input = { groups: clear ? null : value.split(",").map((g) => g.trim()).filter(Boolean) };
else {
const policy = clear ? { ok: true as const, value: null } : value.startsWith("@") ? readPolicyOption({ policyFile: value.slice(1) }) : { ok: true as const, value };
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
input = { policy: policy.value };
}
const res = registry.setPersonPlacement(entityId, input);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, entityId, field, path: res.path }));
return;
}
if (field.startsWith("binding.")) {
const before = registry.showHuman(entityId);
if (!before.ok) { console.error(`refused: ${before.error}`); process.exitCode = 1; return; }
@@ -634,141 +600,7 @@ Examples:
];
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
// Their placement and, if it was them, the default go too; a later read would ignore them anyway, with a warning.
const { removeParticipantEntry } = await import("@openrig/daemon/gateway-human-registry");
const side = removeParticipantEntry(entityId, undefined, { kind: "person" });
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}),
participantFile: side.ok ? { path: side.path } : { error: side.error } }));
});
// ── Participants beyond people (0.7.3): shared channels, organizations, groups, readable policy and the default. All of
// it lives in gateway/participants.yaml, which older daemons don't read, so a rollback still loads every person.
const channel = cmd.command("channel").description("Shared-channel participants: one vetted Slack channel is one participant (<id>@external), not a person per sender");
channel
.command("add <id>")
.description("Register a shared channel")
.requiredOption("--display-name <name>", "Human-readable name, e.g. \"Acme customers\"")
.requiredOption("--binding <slack:connectorRef:secretsRef:channelId>", "The channel: connector kind (slack), connector label, vault pointer for its secrets (never the secret), and the Slack channel id")
.option("--organization <id>", "Its organization (create it first with rig gateway org set)")
.option("--group <id>", "A group it belongs to (repeatable)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
.option("--policy <text>", "Readable policy for agents answering this channel (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--replace", "Explicitly replace an existing channel (no silent overwrite)")
.action(async (id: string, opts: { displayName: string; binding: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; replace?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const parts = opts.binding.split(":");
if (parts.length < 4 || parts[0] !== "slack") { console.error("refused: --binding must be slack:<connectorRef>:<secretsRef>:<channelId>"); process.exitCode = 1; return; }
const res = registry.setSharedChannel(id, {
displayName: opts.displayName,
channel: { connector: "slack", connectorRef: parts[1]!, secretsRef: parts.slice(2, -1).join(":"), channelId: parts[parts.length - 1]! },
organization: opts.organization, groups: opts.group, policy: policy.value,
}, { replace: !!opts.replace });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, id, address: `${id}@${registry.ADDRESS_DOMAIN}`, path: res.path }));
});
channel
.command("remove <id>")
.description("Remove a shared channel: refuses while non-terminal rows are addressed to it (--force removes anyway)")
.option("--force", "Remove despite KNOWN non-terminal rows")
.action(async (id: string, opts: { force?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const side = registry.readParticipantsFile();
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
if (side.file.participants[id]?.kind !== "shared-channel") { console.error(`refused: no shared channel "${id}"`); process.exitCode = 1; return; }
const address = `${id}@${registry.ADDRESS_DOMAIN}`;
const rowsRes = await queueRows(address);
if (!rowsRes.ok) { console.error(`refused: the queue could not be checked for rows addressed to ${address} (${rowsRes.error}); --force doesn't override an unchecked board`); process.exitCode = 1; return; }
if (rowsRes.rows.length && !opts.force) {
console.error(`refused: ${rowsRes.rows.length} non-terminal row(s) are addressed to ${address} (${rowsRes.rows.map((r) => r.id).join(", ")}); pass --force to remove anyway`);
process.exitCode = 1;
return;
}
const res = registry.removeParticipantEntry(id, undefined, { kind: "shared-channel" });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, removed: id, path: res.path, ...(rowsRes.rows.length ? { orphanedRows: rowsRes.rows.map((r) => r.id) } : {}) }));
});
const org = cmd.command("org").description("Organizations: the outer layer of participants, with readable policy agents apply");
org
.command("set <id>")
.description("Create or update an organization")
.option("--name <name>", "Display name (required when new)")
.option("--policy <text>", "Readable policy for agents working with this organization (text, not enforced; \"\" clears it)")
.option("--policy-file <path>", "Read the policy text from a file")
.action(async (id: string, opts: { name?: string; policy?: string; policyFile?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const res = registry.setOrganization(id, { name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, organization: id, path: res.path }));
});
const group = cmd.command("group").description("Groups inside an organization, with readable policy agents apply");
group
.command("set <id>")
.description("Create or update a group")
.option("--organization <id>", "Its organization (required when new)")
.option("--name <name>", "Display name (required when new)")
.option("--policy <text>", "Readable policy for agents working with this group (text, not enforced; \"\" clears it)")
.option("--policy-file <path>", "Read the policy text from a file")
.action(async (id: string, opts: { organization?: string; name?: string; policy?: string; policyFile?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const res = registry.setGroup(id, { organization: opts.organization, name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, group: id, path: res.path }));
});
cmd
.command("participants")
.description("List people and shared channels, with organization, groups, policy and the default")
.option("--organization <id>", "Only this organization's participants")
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
for (const line of describePlacement(p)) console.log(line);
}
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
for (const warning of view.warnings) console.log(`warning: ${warning}`);
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
});
cmd
.command("default [entityId]")
.description("Show or choose the default person: the one escalations, the notice digest, Mission Control's my-queue and pairing go to when no person is named")
.option("--clear", "Clear the chosen default (with one person, they stay the default)")
.action(async (entityId: string | undefined, opts: { clear?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (entityId || opts.clear) {
if (entityId && opts.clear) { console.error("refused: pass an id or --clear, not both"); process.exitCode = 1; return; }
const res = registry.setDefaultPerson(opts.clear ? null : entityId!);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
}
const people = registry.projectHumans();
if (!people.ok) { console.error(`refused: ${people.error}`); process.exitCode = 1; return; }
const chosen = registry.resolveDefaultPerson(people.entities);
console.log(JSON.stringify(chosen.person
? { ok: true, default: chosen.person.entityId, address: chosen.person.address, source: chosen.source }
: { ok: true, default: null, reason: chosen.source === "none" ? chosen.reason : null }));
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
});
return cmd;
@@ -791,26 +623,3 @@ function describeApplication(a: Record<string, any>): string {
const effects = (a.effects as Array<Record<string, any>>).map((e) => ` ${e.consumer}: ${e.state}${e.detail ? ` (${e.detail})` : ""}`);
return [head, ...(effects.length ? effects : [" no effects"])].join("\n");
}
/** --policy or --policy-file (never both): the readable policy text agents read. Absent is undefined. */
function readPolicyOption(opts: { policy?: string; policyFile?: string }): { ok: true; value: string | undefined } | { ok: false; error: string } {
if (opts.policy !== undefined && opts.policyFile !== undefined) return { ok: false, error: "pass --policy or --policy-file, not both" };
if (opts.policyFile === undefined) return { ok: true, value: opts.policy };
try {
return { ok: true, value: readFileSync(opts.policyFile, "utf8") };
} catch (error) {
return { ok: false, error: `--policy-file: ${(error as Error).message}` };
}
}
/** One participant's organization, groups, default and policy, in plain lines. */
function describePlacement(p: { organization: { id: string; name: string } | null; groups: Array<{ id: string; name: string }>; default: boolean; policy: Array<{ source: string; text: string }> }): string[] {
const lines = [
` organization: ${p.organization ? `${p.organization.id} (${p.organization.name})` : "none"}`,
` groups: ${p.groups.length ? p.groups.map((g) => `${g.id} (${g.name})`).join(", ") : "none"}`,
];
if (p.default) lines.push(" default: yes (escalations, the notice digest and other unaddressed needs go to this person)");
if (!p.policy.length) lines.push(" policy: none");
for (const part of p.policy) lines.push(` policy, from ${part.source}:`, ...part.text.trimEnd().split("\n").map((line) => ` ${line}`));
return lines;
}
+23 -160
View File
@@ -1,19 +1,12 @@
// OPR.0.4.4.13 — the host-registry verbs (add / list / doctor — arch
// R13-2a originally: no edit/remove/tunnel/bootstrap verbs; hand-editing
// hosts.yaml remains the path for exotica; the bootstrap ships as script +
// runbook). OPR.0.4.6.MH1 FR-1 extends the family with `select` — the
// persisted host-selection pointer (kubectl current-context shape). The
// pointer lives in the config twins as `host.selected`; this verb is a
// THIN CLIENT of the daemon config write (one write path for CLI+UI
// R13-2a: no edit/remove/tunnel/bootstrap verbs; hand-editing hosts.yaml
// remains the path for exotica; the bootstrap ships as script + runbook).
// OPR.0.4.6.MH1 FR-1 extends the family with `select` — the persisted
// host-selection pointer (kubectl current-context shape). The pointer
// lives in the config twins as `host.selected`; this verb is a THIN
// CLIENT of the daemon config write (one write path for CLI+UI
// convergence, arch ruling); reads resolve from the local ConfigStore
// (env > file > default — zero new daemon lookups on read paths).
// Issue #2128 (maintainer-approved boundary) extends the family again with
// `edit` and `remove`: the SAME parity-pinned writer contract as add — id
// and transport stay fixed on an edit, unknown fields fail loud, empty
// string clears an optional field, and remove deletes only the registry
// entry (no credential files, no learned identity, no selection change).
// When the selected host is edited or removed, the selection outcome is
// shown plainly — never a silent switch.
//
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
// file NAMES) only — no code path in this file resolves a bearer value
@@ -25,12 +18,11 @@ import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
import { parseFeedHostSubscriptionKey } from "../config-store.js";
// ---------------------------------------------------------------------------
// rig host pair — the approval wait is BOUNDED.
@@ -79,46 +71,6 @@ export interface DoctorDeps {
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
* Optional like the learner: absent means doctor makes no claim about where replies go. */
replyRoute?: (hostId: string) => HostEntry | null;
/** The self-id learned for a registry alias, if any. */
learnedHostId?: (alias: string) => string | null;
}
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
export function replyRouteFromDisk(hostId: string): HostEntry | null {
const loaded = loadHostRegistry();
if (!loaded.ok) return null;
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
return resolved.ok ? resolved.host : null;
}
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
* from here whether that other entry still reaches the machine. */
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
return {
step: "reply-route",
status: "unknown",
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
};
}
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
return {
step: "reply-route",
status: "fail",
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
};
}
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
@@ -162,8 +114,6 @@ function defaultDoctorDeps(): DoctorDeps {
sock.on("error", () => { sock.destroy(); resolve("closed"); });
}),
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
replyRoute: replyRouteFromDisk,
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
};
}
@@ -213,8 +163,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
});
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
if (routeRow) rows.push(routeRow);
return rows; // later legs are meaningless without the shell
}
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
@@ -307,28 +255,20 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
if (obs.outcome === "conflict") {
rows.push({
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
});
} else {
// Ask the route rig send takes rather than assume it: an entry listed earlier in
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
const id = obs.binding.hostId;
const winner = deps.replyRoute?.(id) ?? null;
const routedHere = winner?.id === host.id;
rows.push({
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
: `host identity '${id}' confirmed`,
});
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
}
rows.push(obs.outcome === "conflict"
? {
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
}
: {
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
: `host identity '${obs.binding.hostId}' confirmed`,
});
}
} catch {
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
@@ -336,8 +276,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
}
} catch (err) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
if (routeRow) rows.push(routeRow);
return rows;
}
rows.push({
@@ -543,21 +481,6 @@ function authPointer(h: HostEntry): string {
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
}
function printHostNextSteps(host: HostEntry): void {
console.log(`Verify it: rig host doctor ${host.id}`);
if (host.transport !== "http") {
console.log("Remote FEED requires an HTTP host entry. Pair its daemon URL with: rig host pair <url>");
return;
}
const key = `feed.subscriptions.${host.id}.enabled`;
if (!parseFeedHostSubscriptionKey(key)) {
console.log(`For remote FEED, register this host with a different id: '${host.id}' is reserved by feed settings.`);
return;
}
console.log("Remote FEED is off by default. To include this host's attention items, run locally:");
console.log(` rig config set ${key} true`);
}
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
@@ -593,7 +516,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
printHostNextSteps(res.entry);
console.log(`Verify it: rig host doctor ${res.entry.id}`);
});
cmd
@@ -694,66 +617,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
console.log(`This host is now named: ${trimmed}`);
});
cmd
.command("edit")
.description("Edit a host entry (id and transport stay fixed; an empty string clears an optional field)")
.argument("<id>", "A registered host id")
.option("--target <target>", "SSH target — ssh transport")
.option("--user <user>", "SSH user — ssh transport (empty string clears the value)")
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
.option("--json", "JSON output")
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
const changes: Record<string, unknown> = {};
if (opts.target !== undefined) changes["target"] = opts.target;
if (opts.user !== undefined) changes["user"] = opts.user;
if (opts.url !== undefined) changes["url"] = opts.url;
if (opts.notes !== undefined) changes["notes"] = opts.notes;
const res = updateHostEntry(id.trim(), changes);
if (!res.ok) {
console.error(res.error);
process.exitCode = 1;
return;
}
const wasSelected = readSelectedHost() === res.entry.id;
if (opts.json) {
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
return;
}
console.log(`Updated host '${res.entry.id}' (${res.entry.transport}) at ${res.path}`);
for (const key of Object.keys(changes) as string[]) {
const rec = (e: HostEntry) => e as unknown as Record<string, unknown>;
const before = rec(res.previous)[key];
const after = rec(res.entry)[key];
if (before !== after) {
console.log(` ${key}: ${before === undefined ? "(unset)" : String(before)} → ${after === undefined ? "(cleared)" : String(after)}`);
}
}
if (wasSelected) console.log(`note: '${res.entry.id}' is the selected host — it was edited in place and the selection still points at it.`);
});
cmd
.command("remove")
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
.argument("<id>", "A registered host id")
.option("--json", "JSON output")
.action((id: string, opts: { json?: boolean }) => {
const res = removeHostEntry(id.trim());
if (!res.ok) {
console.error(res.error);
process.exitCode = 1;
return;
}
const wasSelected = readSelectedHost() === res.entry.id;
if (opts.json) {
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
return;
}
console.log(`Removed host '${res.entry.id}' (${res.entry.transport}) from ${res.path}`);
if (wasSelected) console.log(`note: '${res.entry.id}' was the selected host — the selection still points at '${res.entry.id}' and was NOT changed. Pick another: rig host select <id>`);
});
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
@@ -923,7 +786,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
printHostNextSteps(added.entry);
console.log(`Verify it: rig host doctor ${id}`);
});
cmd
+14 -61
View File
@@ -1,9 +1,9 @@
import { Command } from "commander";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
import { createMcpServer } from "../mcp-server.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -23,46 +23,24 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
.option("--port <port>", "Daemon port override")
.option("--http", "Serve over Streamable HTTP at /mcp instead of stdio")
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
.addHelpText("after", `
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
server/discover) and clients that open with 2025's initialize handshake.
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
through a tunnel or proxy: they arrive on loopback too. If the token file then goes missing or can't be read, every
request is refused; serving unauthenticated again takes a restart with no token file. TLS comes from the tunnel or
proxy, not from this server.
With --grant-service, every request needs a key the grant service issued, checked with that service on each request
for this daemon's instance (its self id); the token file isn't read. A caller acts as the seat its grant names, which
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
the grant service can't answer, the request is refused.
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
proxy, not from this server.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string }) => {
const deps = getDepsF();
let daemonUrl: string;
if (opts.port) {
const daemonPort = parseInt(opts.port, 10);
if (Number.isNaN(daemonPort)) {
if (isNaN(daemonPort)) {
console.error("Invalid port number");
process.exitCode = 1;
return;
}
if (daemonPort < 1 || daemonPort > 65535) {
console.error(`Warning: daemon port ${daemonPort} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`);
}
daemonUrl = `http://127.0.0.1:${daemonPort}`;
} else {
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -70,48 +48,29 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
daemonUrl = getDaemonUrl(status);
}
if (!opts.http && (opts.grantService || opts.grantServiceHeaderFile)) {
console.error("--grant-service and --grant-service-header-file need --http");
process.exitCode = 1;
return;
}
if (opts.grantServiceHeaderFile && !opts.grantService) {
console.error("--grant-service-header-file needs --grant-service");
process.exitCode = 1;
return;
}
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
process.exitCode = 1;
return;
}
const oauth = opts.oauthResource !== undefined && opts.oauthIssuer !== undefined ? { resource: opts.oauthResource, issuer: opts.oauthIssuer } : undefined;
if (oauth && !(opts.http && opts.grantService)) {
console.error("--oauth-resource and --oauth-issuer need --http --grant-service");
process.exitCode = 1;
return;
}
if (opts.http) {
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437");
return;
}
const client = deps.clientFactory(daemonUrl);
const server = createMcpServer(client);
const transport = new StdioServerTransport();
let finish!: () => void;
const closed = new Promise<void>((resolve) => { finish = resolve; });
server.server.onclose = finish;
process.stdin.once("end", finish);
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
try {
// EOF is a normal stdio disconnect, not an unresolved top-level await.
await server.connect(transport);
if (process.stdin.readableEnded) finish();
await closed;
} finally {
process.stdin.removeListener("end", finish);
process.removeListener("SIGINT", finish);
process.removeListener("SIGTERM", finish);
await served.close();
await server.close();
}
});
@@ -168,8 +127,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
return cmd;
}
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
oauth?: { resource: string; issuer: string }): Promise<void> {
async function serveHttp(daemonUrl: string, bind: string): Promise<void> {
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
if (!match) {
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
@@ -180,21 +138,16 @@ async function serveHttp(daemonUrl: string, bind: string, grantService?: string,
const tokenFile = mcpTokenFilePath();
let served;
try {
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile });
} catch (err) {
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
process.exitCode = 1;
return;
}
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
console.error(grantService
? `OpenRig MCP over HTTP at ${served.url}: each request needs a key from the grant service, checked with ${grantService}.`
: served.authenticated
console.error(served.authenticated
? `OpenRig MCP over HTTP at ${served.url}: each request needs a bearer credential from ${tokenFile}.`
: `OpenRig MCP over HTTP at ${served.url}: no grants, so loopback only, acting as ${seat || "no seat"}, declared and not authenticated. Add a grant (rig mcp token add) before reaching it through a tunnel or proxy.`);
if (oauth) console.error(`OAuth discovery names ${oauth.resource}, issued by ${oauth.issuer}.`);
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
+13 -124
View File
@@ -1,4 +1,3 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError } from "../client.js";
@@ -23,8 +22,8 @@ export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
type SeatView = { home?: string; activity?: string; peers: Peer[] };
type SwitchReport = { credentialKind?: "api-key"; home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean; effort?: string };
type SwitchReport = { home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
const SWITCH_TIMEOUT_MS = 120_000;
@@ -39,25 +38,23 @@ function printSeat(seat: SeatView): void {
}
}
type AccountOpts = { toAccount?: string; tokenFile?: string; apiKeyFile?: string; storedLogin?: boolean };
type AccountOpts = { toAccount?: string; tokenFile?: string; storedLogin?: boolean };
/** The account a switch names: a Codex login's saved name, a Claude token's label with its file, or a Claude seat's stored
* login. Null when neither --to-account nor --stored-login was given. */
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null; apiKeyFile?: string } | null {
if (opts.apiKeyFile !== undefined && (opts.tokenFile !== undefined || opts.storedLogin)) return null;
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null } | null {
if (opts.storedLogin) return { toAccount: "stored-login", tokenFile: null };
if (!opts.toAccount) return null;
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}), ...(opts.apiKeyFile !== undefined ? { apiKeyFile: opts.apiKeyFile } : {}) };
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}) };
}
function printClaude(claude: { credentialKind?: "api-key"; label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude ${claude.credentialKind === "api-key" ? "API key" : "token"}: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
function printClaude(claude: { label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude token: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
if (claude.instead) console.log(`instead of the stored login, the seat would use ${claude.instead}`);
if (claude.checked.length) console.log(`checked for anything that outranks it: ${claude.checked.join("; ")}`);
if (claude.notChecked.length) console.log(`not checked (the seat's /status shows what it uses): ${claude.notChecked.join("; ")}`);
}
/** Build provider views and account switches; an isolated-home switch first requires the daemon to acknowledge its plan. */
export function providerCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("provider").description(
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
@@ -513,7 +510,6 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--json", "Output as parseable JSON")
.action(async (opts: AccountOpts & { seat: string; json?: boolean }) => {
@@ -529,8 +525,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}),
...(target.apiKeyFile !== undefined ? { apiKeyFile: target.apiKeyFile } : {}) });
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}) });
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView;
claude?: Parameters<typeof printClaude>[0] }>(`/api/provider/precheck?${qs.toString()}`);
if (handleHttpError(res, "precheck")) return;
@@ -546,19 +541,12 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("switch")
.description("Move one seat to subscription or API credentials, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.description("Move one seat to another subscription login, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--force-unsafe", "Kept for scripts; a seat's checks can't be overridden, since each means nothing safe to restart or no such login")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Move to an isolated native account home with a fresh successor and handover; no shared login is changed")
.option("--reason <text>", "Required with --account-home or --clear-account-home")
.option("--model <id>", "With --account-home or --clear-account-home, the successor's model")
.option("--codex-config-profile <name>", "With --account-home or --clear-account-home, the successor's Codex profile")
.option("--dry-run", "With --account-home or --clear-account-home, show the plan without changing anything")
.option("--json", "Output as parseable JSON")
.addHelpText("after", `
The daemon plans the seat's in-place restart first (rig seat restart --plan), so a refusal changes nothing. It then saves the
@@ -590,28 +578,9 @@ save it. That environment is Claude's, so the tools, hooks and MCP servers it st
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
connectors or Remote Control; its local MCP servers still work. --stored-login moves it back to the config dir's stored login;
a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the report says which token it will use. Claude's
/status shows token auth with no account email, so the label is yours, not an observed account.
For Claude API billing use --to-account <label> --api-key-file <absolute path to a 0600 file>. The key reaches only the
resumed child's environment, never command arguments. API credentials fail closed if their file is unavailable. Existing
model, permissions, config home and conversation are retained; in-flight work is interrupted. Claude may require native
API-key approval before the first turn. OpenRig does not enforce API budgets or choose when to fall back.
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before relying on a stored login.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean; accountHome?: string; clearAccountHome?: boolean; reason?: string; model?: string; codexConfigProfile?: string; dryRun?: boolean }) => {
if (opts.apiKeyFile !== undefined && (opts.accountHome !== undefined || opts.clearAccountHome)) {
console.error("Use --api-key-file for an in-place credential switch; select or clear an account home in a separate switch.");
process.exitCode = 1; return;
}
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.toAccount !== undefined || opts.storedLogin)) {
console.error("Use --clear-account-home alone, without --account-home, --token-file, --to-account or --stored-login.");
process.exitCode = 1; return;
}
const target = opts.clearAccountHome ? { toAccount: "default" } : targetAccount(opts);
/status shows token auth with no account email, so the label is yours, not an observed account.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean }) => {
const target = targetAccount(opts);
if (!target) {
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
process.exitCode = 1;
@@ -622,85 +591,6 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
process.exitCode = 1;
return;
}
if (opts.accountHome !== undefined || opts.clearAccountHome) {
if (!opts.reason?.trim() || opts.storedLogin) {
console.error(opts.clearAccountHome ? "Pass --reason with --clear-account-home."
: "With --account-home pass --reason and --to-account, without --stored-login. A Claude subscription may also name --token-file.");
process.exitCode = 1;
return;
}
let preview: { status: number; data: Record<string, unknown> };
try {
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
const runtime = status.data.runtime;
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
preview = await client.post<Record<string, unknown>>(`/api/seat/set-runtime/${encodeURIComponent(opts.seat)}`, {
runtime, ...(opts.clearAccountHome ? { clearAccountHome: true } : { accountHome: opts.accountHome }), reason: opts.reason, dryRun: true,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.toAccount } : {}),
}, { timeoutMs: 30_000 });
} catch {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
const token = opts.tokenFile ? { label: opts.toAccount!, tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
const refusal = { ok: false, code: "account_home_switch_unavailable", message: preview.data["message"]
?? "The daemon did not acknowledge the selected account home and token. No switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (opts.dryRun) {
if (opts.json) console.log(JSON.stringify(preview.data));
else {
console.log(`Account-home plan for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. Nothing changed.`);
const settings = (preview.data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
console.log(`Settings on ${String(preview.data["to"])}:`);
for (const [name, setting] of Object.entries(settings)) console.log(` ${name}: ${setting.value ?? "none"} (${setting.source})`);
const permission = preview.data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.mode} (${permission.source})`);
const dropped = (preview.data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${String(preview.data["from"])}: ${dropped.map(choice => `${choice.setting} ${choice.value}`).join("; ")}.`);
console.log("Costs:");
for (const cost of (preview.data["costs"] as string[] | undefined) ?? []) console.log(` - ${cost}`);
}
return;
}
let result;
try {
result = await client.post<Record<string, unknown>>("/api/provider/switch", {
seat: opts.seat, ...(opts.clearAccountHome ? { clearAccountHome: true } : { ...target, accountHome: opts.accountHome }), reason: opts.reason, dryRun: false,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
}, { timeoutMs: 300_000 });
} catch (error) {
if (!(error instanceof DaemonTimeoutError)) throw error;
result = { status: 202, data: { ok: false, code: "runtime_switch_outcome_unknown",
message: `The daemon may still be switching. Inspect rig seat status ${opts.seat}; do not retry the switch.` } };
}
if (opts.json) console.log(JSON.stringify(result.data));
else if (result.data["recoveryPending"] === true) {
console.error(`Recovery is still pending for ${opts.seat}; the account-home switch is not complete.`);
if (typeof result.data["message"] === "string") console.error(result.data["message"]);
if (typeof result.data["guidance"] === "string") console.error(result.data["guidance"]);
for (const warning of (result.data["warnings"] as string[] | undefined) ?? []) console.error(`Warning: ${warning}`);
}
else if (result.data["ok"] === true) console.log(`Account-home switch for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. A fresh conversation carries the handover; the account is not verified until a real tool turn.`);
else console.error(result.data["message"] ?? "The account-home switch did not complete.");
process.exitCode = result.status >= 400 || result.data["ok"] !== true || result.data["recoveryPending"] === true ? 1 : 0;
return;
}
if (opts.dryRun || opts.reason || opts.model || opts.codexConfigProfile) {
console.error("--reason, --model, --codex-config-profile and --dry-run require --account-home or --clear-account-home.");
process.exitCode = 1;
return;
}
type SwitchResponse = { status: number; data: { outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport } };
let res: SwitchResponse;
const startedAt = new Date().toISOString();
@@ -732,10 +622,9 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
console.log("undo restores saved credential bytes only; sign-in is unverified. Saved credentials may expire, be revoked, or be superseded by refresh elsewhere. Restart the affected seat and use the existing account verification; fresh sign-in may be required.");
}
} else if (seat) {
console.log(`Claude ${seat.credentialKind === "api-key" ? "API key" : "token"}: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
console.log(`Claude token: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
if (seat.effort) console.log(`effort: ${seat.effort}`);
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
}
+23 -81
View File
@@ -144,7 +144,7 @@ function terminalWakeOutcome(wakeResult: string | null): TerminalDeliveryResult[
/** Only these known states mean "not delivered yet, nothing went wrong". An unknown value never gets that label. */
function isKnownUnconfirmed(wakeResult: string | null, outcome: TerminalDeliveryResult["outcome"]): boolean {
if (outcome === "not-requested" || wakeResult === null || wakeResult === "delivered-ack-pending") return true;
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:") || wakeResult.startsWith("wake-accepted:");
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:");
}
function terminalDeliveryResult(
@@ -165,9 +165,7 @@ function terminalDeliveryResult(
"claimed": "Terminal receipt unconfirmed, but the seat has claimed the item (claimed).",
"retained": `The wake is held (${wakeResult}): nothing was typed into the pane. A held create wake is not retried automatically; inspect it with rig seat held-messages ${seat}.`,
"failed": `The wake failed (${wakeResult}).`,
"indeterminate": wakeResult?.startsWith("wake-accepted:")
? `The member's trigger accepted the ring (${wakeResult}). That isn't pickup: its claim is.`
: `Terminal receipt unconfirmed (${wakeResult}).`,
"indeterminate": `Terminal receipt unconfirmed (${wakeResult}).`,
"still-pending": waitedMs === null ? "No terminal receipt was recorded before the claim." : `No terminal receipt was recorded within ${waitedMs}ms.`,
"not-requested": "No terminal wake was requested (--no-nudge).",
};
@@ -356,18 +354,10 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
const PRE_WRITE_CREATE_REFUSALS = new Set([
"human_route_fields_required", "invalid_human_notification", "invalid_human_questions", "reply_to_requires_update", "reply_to_not_found",
]);
/** #1642: why a create provably wrote nothing, for the refusals that come before any write: the
* selected daemon refused the forward before contacting another host (its registry was unreadable,
* the host unknown or SSH-declared), refused a local create to a rig it doesn't have, or refused the
* new row's fields while validating it. Any other failure, including a timeout or a remote's error,
* may follow a commit, so it returns null. */
* the host unknown or SSH-declared), or refused a local create to a rig it doesn't have. Any other
* failure, including a timeout or a remote's error, may follow a commit, so it returns null. */
function provenCreateRefusal(status: number, body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const { error, failureClass, hostId } = body as { error?: unknown; failureClass?: unknown; hostId?: unknown };
@@ -378,9 +368,6 @@ function provenCreateRefusal(status: number, body: unknown): string | null {
if (status === 400 && error === "unknown_destination_rig") {
return "the daemon refused it because the destination's rig doesn't exist on that daemon.";
}
if (status === 400 && typeof error === "string" && PRE_WRITE_CREATE_REFUSALS.has(error)) {
return `the daemon refused it before writing anything (${error}).`;
}
return null;
}
@@ -526,10 +513,13 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
return null;
}
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
let remote = remoteHostOf(hostId);
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it: this one for a local row, the destination's registered daemon for a
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. It never
* sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
let reader = client;
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
if (remote) {
@@ -537,45 +527,15 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
if (!match?.ok || match.host.transport !== "http") {
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
return { error: `from host '${remote}' (${why})` };
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
}
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
reader = deps.clientFactory(match.host.url);
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
}
return { reader, options };
}
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
if (!remoteHostOf(hostId)) return client;
let target: ReturnType<typeof queueReadTarget> | undefined;
let source: ReturnType<typeof storedSourceOf> | undefined;
return {
get: async <T>(path: string) => {
const selected = await (target ??= queueReadTarget(deps, client, hostId));
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
const response = await selected.reader.get<T>(path, selected.options);
const row = response.data as Record<string, unknown> | undefined;
if (response.status === 200 && (!row || row.qitemId !== qitemId
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
throw new Error("receipt row does not match the created qitem");
}
return response;
},
};
}
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
const target = await queueReadTarget(deps, client, hostId);
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
try {
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
if (res.status === 200 && res.data?.qitemId === qitemId) {
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
if (differs === "unread") {
@@ -801,19 +761,6 @@ function resolveCurrentSession(explicit: string | undefined, optionName: string)
return undefined;
}
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from OPENRIG_SESSION_NAME),
* where the named flag is deprecated and ignored: the error names what actually sets the actor, never the flag. */
function requireSeatSession(ignoredFlag: string): string | undefined {
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (session) return session;
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set ` +
`OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --${ignoredFlag} is ignored: the ` +
"actor is the seat's own identity.");
process.exitCode = 1;
return undefined;
}
function extractRigName(sessionName: string): string | undefined {
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
return sessionRigOf(sessionName);
@@ -1109,15 +1056,10 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
if (status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
console.error(`Warning: ${createWarning.message}`);
}
// An ask to a shared channel was accepted; nothing there will answer it (0.7.3).
const advisories = Array.isArray(created?.advisories) ? created.advisories as Array<{ code?: unknown; message?: unknown }> : [];
for (const advisory of advisories) {
if (status < 400 && advisory.code === "shared_channel_correspondence" && typeof advisory.message === "string") console.error(`Warning: ${advisory.message}`);
}
if (opts.verify && status < 400) {
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
const delivery = createdId
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
return;
@@ -1175,7 +1117,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.action(async (qitemId: string, opts: { destination?: string; json?: boolean; full?: boolean }) => {
// P21 I3 reconcile: the claimant is DERIVED from the seat env — --destination deprecated + ignored,
// no body claim. Verify the env (the header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("destination")) return;
if (!resolveCurrentSession(undefined, "destination")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
@@ -1193,7 +1135,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean; full?: boolean }) => {
// P21 I3 reconcile: the releaser is DERIVED from the seat env — --destination deprecated + ignored,
// no body claim. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("destination")) return;
if (!resolveCurrentSession(undefined, "destination")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
@@ -1254,7 +1196,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
// header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal). Matches `resolve`.
if (!requireSeatSession("actor")) return;
if (!resolveCurrentSession(undefined, "actor")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
@@ -1318,7 +1260,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
}) => {
// P21 I3 reconcile: actor DERIVED from the seat env (X-OpenRig-Session) — --actor deprecated +
// ignored, no body actorSession. Verify the env, else the daemon returns 400 actor_required (no seat identity to record). Matches `resolve`.
if (!requireSeatSession("actor")) return;
if (!resolveCurrentSession(undefined, "actor")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
@@ -1358,7 +1300,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// P21: the resolver is DERIVED from the seat env (X-OpenRig-Session, stamped by DaemonClient) —
// --actor is deprecated + ignored. The pre-check verifies the env (the header source); else the
// daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("actor")) return;
if (!resolveCurrentSession(undefined, "actor")) return;
const deps = getDeps();
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
await withClient(deps, async (client) => {
@@ -1418,7 +1360,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
}) => {
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("from")) return;
if (!resolveCurrentSession(undefined, "from")) return;
// slice-08 OPR.0.4.7.8 — body-input parity. Resolve through the shipped
// resolveQueueBody ONLY when a body source is supplied; neither preserves
// today's source-body default (POST body undefined). Both/invalid reject
@@ -1522,7 +1464,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
}) => {
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("from")) return;
if (!resolveCurrentSession(undefined, "from")) return;
// slice-08 OPR.0.4.7.8 — body-input parity (same contract as handoff):
// resolve only when a body source is supplied; neither keeps the
// source-body default; both/invalid reject before daemon contact.
@@ -1971,7 +1913,7 @@ Examples:
}
// P21 I3 reconcile: the sender is DERIVED from the seat env (X-OpenRig-Session) — --sender
// deprecated + ignored, no body senderSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("sender")) return;
if (!resolveCurrentSession(undefined, "sender")) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
+4 -3
View File
@@ -180,7 +180,8 @@ function emitOperatingPosture(value: OperatingPosture | undefined): void {
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
*
* Reject:
* - explicit qualifier with global_host (a review finding): operators who type
* - explicit qualifier with global_host (BLOCKING re-verify finding from
* guard qitem-20260518044650): operators who type
* `--scope global_host --qualifier <id>` get an error and the daemon
* is never contacted. The CLI does NOT silently drop the qualifier.
* - missing qualifier for any non-global scope.
@@ -268,7 +269,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
return;
}
// Review finding: when --scope is
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
// explicit, validate scope + qualifier LOCALLY before touching
// the daemon. Otherwise an invalid operator invocation
// (e.g. `--scope global_host --qualifier X`) surfaces as
@@ -541,7 +542,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
return;
}
const scope = scopeArg as Scope;
// Review finding: never silently drop
// BLOCKING re-verify (qitem-20260518044650): never silently drop
// an explicit operator-supplied qualifier on global_host on unset.
// Same hazard class as set; same shared helper.
const normalized = normalizeScopeQualifier(scope, qualifierArg);
+60 -19
View File
@@ -1,32 +1,81 @@
import { modelObservation, unknownModelObservation, formatModelObservation } from "../lib/model-observation.js";
import { Command } from "commander";
import { closeSync, constants, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { ConfigStore } from "../config-store.js";
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
export { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake };
type Row = Record<string, unknown>;
type Reference = { seat: string; host: string };
export interface RosterMember extends Reference {
capabilities: string[];
engagement: string[];
use_when: string;
why: string;
caveat?: string;
}
export interface Roster {
version: 1;
id: string;
name: string;
purpose: string;
curator: Reference;
updated_at: string;
members: RosterMember[];
}
export interface RosterInventory {
readAt: string;
nodes: Row[];
sources: Row[];
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
localHostId?: string | null;
}
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => text(x) !== null);
const reference = (v: unknown): v is Row & Reference => object(v) && text(v.seat) !== null && text(v.host) !== null;
/** Select authored fields only: a file cannot supply runtime or observed-model facts. */
export function parseRoster(value: unknown): Roster {
if (!object(value) || value.version !== 1 || !reference(value.curator) ||
![value.id, value.name, value.purpose, value.updated_at].every(v => text(v) !== null) || !Array.isArray(value.members)) {
throw new Error("Expected a version 1 roster with id, name, purpose, curator, updated_at and members");
}
const members = value.members.map((m): RosterMember => {
if (!reference(m) || !object(m) || !strings(m.capabilities) || !strings(m.engagement) ||
text(m.use_when) === null || text(m.why) === null || (m.caveat !== undefined && typeof m.caveat !== "string")) {
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat)");
}
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }) };
});
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
}
export function readRosters(folder: string): { rosters: Roster[]; warnings: string[] } {
const rosters: Roster[] = [], warnings: string[] = [];
let names: string[];
try { names = readdirSync(folder).filter(n => n.endsWith(".json")).sort(); }
catch (error) { return { rosters, warnings: [`Cannot read roster folder ${folder}: ${(error as Error).message}`] }; }
for (const name of names) {
let fd: number | undefined;
try {
// Inspect the opened target before reading; NONBLOCK also handles a FIFO
// without waiting for a writer. Regular-file symlinks remain readable.
fd = openSync(join(folder, name), constants.O_RDONLY | constants.O_NONBLOCK);
if (!fstatSync(fd).isFile()) throw new Error("Not a regular file; skipped");
rosters.push(parseRoster(JSON.parse(readFileSync(fd, "utf8"))));
} catch (error) { warnings.push(`${name}: ${(error as Error).message}`); }
finally { if (fd !== undefined) closeSync(fd); }
}
return { rosters, warnings };
}
// Reuse ps's local routing and observation-only HTTP fan-out, including its partial failures.
// Explicit fields keep native transcripts, resume tokens and fresh pane captures out of this read.
const fields = "rigId,logicalId,canonicalSessionName,hostSelfId,runtime,model,modelObservation,sessionStatus,agentActivity";
export async function readRosterInventory(): Promise<RosterInventory> {
const nodes: Row[] = [], sources: Row[] = [];
let localHostId: string | null = null;
for (const remote of [false, true]) {
const argv = [fileURLToPath(new URL("../index.js", import.meta.url)), "ps", "--no-cleanup", "--nodes", "-A", "--json", "--fields", fields,
...(remote ? ["--all-hosts"] : [])];
@@ -40,7 +89,6 @@ export async function readRosterInventory(): Promise<RosterInventory> {
const entries = Array.isArray(data) ? data : object(data) ? data.entries ?? data.items : null;
if (!Array.isArray(entries)) throw new Error("ps returned no node array");
nodes.push(...entries.filter(object));
if (!remote) localHostId = entries.filter(object).map((n) => text(n.hostSelfId)).find((id) => id !== null) ?? null;
sources.push({ scope, status: result.error || result.stderr.trim() ? "partial" : "ok",
...(object(data) && Array.isArray(data.hosts) ? { hosts: data.hosts } : {}),
...(result.stderr.trim() ? { warning: result.stderr.trim() } : {}),
@@ -49,7 +97,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
}
}
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
return { readAt: new Date().toISOString(), nodes, sources };
}
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
@@ -66,9 +114,7 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
return { ...member, live: {
status: !node ? "unknown" : current ? "reported" : "stale",
reason: !node ? (matches.length ? "Conflicting node observations"
: member.hosted ? "A hosted member: it has no pane, and OpenRig doesn't observe it between calls"
: "No exact hostSelfId and seat match; check observation gaps")
reason: !node ? (matches.length ? "Conflicting node observations" : "No exact hostSelfId and seat match; check observation gaps")
: current ? "Registry, activity and labelled last model observation; not a promise about the next turn" : "Seat is not reported running; current runtime and model are unknown",
runtime: current ? text(node?.runtime) : null,
configuredModel: current ? text(node?.model) : null,
@@ -76,11 +122,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
modelObservation: observation,
sessionStatus,
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
// A hosted member's last call to this host's MCP server, for a roster row on this host only: another host's member,
// or one whose host can't be told, is unknown (null), never this host's record for a same-named seat. Its last ring
// is in rig_team.
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
} };
}
@@ -122,7 +163,7 @@ export function rosterCommand(deps: Deps = {}): Command {
else {
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}\n ${m.live.reason}`);
if (!members.length) console.log("No matching members.");
const obs = result.observations as { sources: Row[] };
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
+7 -15
View File
@@ -347,7 +347,7 @@ function buildSliceCreateCommand(): Command {
action: "Pick a slug containing letters or digits.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace, strictOverride: true });
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
let nn = nextSliceNN(mission.absPath);
const title = opts.title ?? titleFromSlug(slug);
@@ -802,16 +802,8 @@ function resolveMissionCreateRoot(override: string | null | undefined, advisorie
fs.existsSync(childMissionsRoot) && fs.statSync(childMissionsRoot).isDirectory();
const directMissionsRoot = path.basename(candidate) === "missions" && !hasChildMissionsRoot;
const workspaceRoot = directMissionsRoot ? path.dirname(candidate) : candidate;
// A named workspace that doesn't exist is refused, not created: a mistyped path would otherwise start a new tree
// there. The configured root is never used in its place (#70).
if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) {
const source = override ? "--workspace" : "OPENRIG_WORK_ROOT";
throw new ScopeCliError({
fact: `The workspace named by ${source} is not an existing directory: ${workspaceRoot}.`,
consequence: "Mission not created; the configured workspace was NOT used as a fallback.",
action: `Point ${source} at an existing workspace, or create ${workspaceRoot} first.`,
});
}
// A missing workspace is created at the named path by the mission's own mkdir, after rendering; it never falls
// back to the configured root. A path that can't be created reports the filesystem's own error there.
// A named workspace uses its project.yaml missions.root, as work positions do; a named missions folder is used as is.
const missionsRoot = directMissionsRoot ? candidate : readProjectMissionsRoot(workspaceRoot, advisories);
@@ -1068,7 +1060,7 @@ function buildResolveNotesCommand(): Command {
function buildAuditCommand(): Command {
return new Command("audit")
.description("Read-only scope audit: flag scope structure findings and show the advisory dependency graph. It doesn't read proof readiness: use rig proof show")
.description("Read-only scope audit: flag scope findings and show the advisory dependency graph")
.requiredOption("--mission <name>", "Mission to audit")
.option("--json", "Machine-readable JSON output")
.action(async (opts, command) => {
@@ -1076,7 +1068,7 @@ function buildAuditCommand(): Command {
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const { classifyScopeItem, SLICE_DIRECTORY_NAME } = await import("../lib/scope/scope-audit.js");
const { classifyScopeItem } = await import("../lib/scope/scope-audit.js");
const missionName = opts.mission as string;
const missionDir = path.join(missionsRoot, missionName);
@@ -1215,7 +1207,7 @@ function buildAuditCommand(): Command {
: null,
});
if (!SLICE_DIRECTORY_NAME.test(entry)) {
if (!/^\d{2,}-/.test(entry)) {
sliceResult.findings.push({
kind: "id_convention_violation",
severity: "high",
@@ -2155,7 +2147,7 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
.option("--workspace <path>", "Workspace root; use its project.yaml missions.root, else missions/. A directory named missions without its own missions/ child is used directly. If the tree is missing, fall back to configured workspace.slices_root, except slice create and approve, which refuse, and mission create, which refuses a path that isn't an existing directory and otherwise creates the missions tree inside it. Without this flag, OPENRIG_WORK_ROOT is tried first with the same exceptions.");
.option("--workspace <path>", "Workspace root; use its project.yaml missions.root, else missions/. A directory named missions without its own missions/ child is used directly. If the tree is missing, fall back to configured workspace.slices_root, except mission create, which creates the named tree, and approve, which refuses. Without this flag, OPENRIG_WORK_ROOT is tried first with the same exceptions.");
const slice = new Command("slice").description("Slice-tier commands");
slice.addCommand(buildSliceLsCommand());
+22 -185
View File
@@ -1,4 +1,3 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
@@ -63,15 +62,13 @@ interface SeatStatusResponse {
};
session_status: string | null;
launch_cwd?: string | null;
permissions_to?: string | null;
runner?: boolean;
runtime_home?: {
variable: string;
home: string | null;
source: "member" | "rig" | "daemon" | "native_default" | "account-switch";
source: "member" | "rig" | "daemon" | "native_default";
ignored?: { source: string; value: string };
};
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
session_reason?: string | null;
startup_status: string | null;
occupant_lifecycle: string;
@@ -163,8 +160,6 @@ interface SeatHandoverMutationResult {
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
sideEffects: {
departingSessionKilled: false;
startupContextDelivered: boolean;
@@ -196,28 +191,22 @@ const LEVEL_WORDS: Record<string, string> = {
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
};
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
console.log(`Rig: ${status.rig_name}`);
console.log(`Logical ID: ${status.logical_id}`);
console.log(`Current occupant: ${display(status.current_occupant)}`);
if (status.launch_cwd !== undefined) console.log(`Working directory for future launches: ${display(status.launch_cwd)}`);
// Only a runner submits permission requests; a plain seat's own prompts are its harness's.
if (status.permissions_to || status.runner) {
console.log(`Runner permission requests answered by: ${status.permissions_to === "deny" ? "no one: refused (--deny)" : status.permissions_to ?? "the default person"}`);
}
if (status.runtime_home) {
const h = status.runtime_home;
const from = h.source === "member" || h.source === "rig" ? `the seat's own, from its ${h.source} env`
: h.source === "account-switch" ? "the seat's selected account home"
: h.source === "daemon" ? "the daemon's" : "the native default";
console.log(`Runtime home (${h.variable}) for launches: ${display(h.home)} (${from}; configured, not proof of the login a running session uses)`);
if (h.ignored) console.log(` Ignored: its ${h.ignored.source} env sets ${h.variable}=${h.ignored.value}, which isn't an absolute path`);
}
if (status.claude_token) {
const t = status.claude_token;
console.log(`Claude ${t.kind === "api-key" ? "API key" : "subscription token"}: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
console.log(`Claude subscription token: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
if (t.notice) console.log(` ${t.notice}`);
}
if (status.typingGuard) {
@@ -350,11 +339,6 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log("The captured startup context (restore packet) was delivered to the successor.");
}
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
} else {
@@ -387,7 +371,6 @@ function printSeatError(error: SeatStatusError, fallback: string): void {
}
}
/** Build seat lifecycle commands, requiring acknowledged plans before isolated account-home changes. */
export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promise<string>; host?: RunOnHostDeps }): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
@@ -405,35 +388,6 @@ export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promis
console.log(JSON.stringify(result.data, null, json ? undefined : 2));
if (result.status >= 400) process.exitCode = result.status >= 500 ? 2 : 1;
};
cmd.command("history")
.requiredOption("--rig <id>", "Exact rig ID")
.requiredOption("--node-id <id>", "Exact durable node ID")
.option("--snapshot-id <id>", "Validate a reference returned by this list")
.option("--history-session-id <id>", "Validate a reference returned by this list")
.option("--json", "Print the daemon response as JSON")
.description("List or validate captured histories eligible for a resume attempt (read-only)")
.action(async (opts: { rig: string; nodeId: string; snapshotId?: string; historySessionId?: string; json?: boolean }) => {
if (!!opts.snapshotId !== !!opts.historySessionId) {
console.error("Supply both --snapshot-id and --history-session-id to validate a history.");
process.exitCode = 1;
return;
}
const deps = getDeps(); const daemon = await getDaemonStatus(deps.lifecycleDeps, { cleanupStaleState: false });
if (!daemonStatusGuard(daemon)) return;
const client = deps.clientFactory(getDaemonUrl(daemon));
const path = `/api/startup/${encodeURIComponent(opts.rig)}/nodes/${encodeURIComponent(opts.nodeId)}/history`;
const response = opts.snapshotId && opts.historySessionId
? await client.post<Record<string, unknown>>(`${path}/validate`, { snapshotId: opts.snapshotId, historySessionId: opts.historySessionId }, { headers: terminalAuthHeaders() })
: await client.get<Record<string, unknown>>(path, { headers: terminalAuthHeaders() });
if (opts.json || response.status >= 400) console.log(JSON.stringify(response.data, null, 2));
else {
const body = response.data as { snapshotId?: string; history?: { historySessionId: string; resumeType: string; tokenState: string }; histories?: Array<{ historySessionId: string; resumeType: string; tokenState: string }> };
console.log(`Current snapshot: ${body.snapshotId ?? "none"}`);
for (const item of body.history ? [body.history] : body.histories ?? []) console.log(`${item.historySessionId} ${item.resumeType} token: ${item.tokenState}`);
if (!body.history && !body.histories?.length) console.log("No eligible captured histories.");
}
if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1;
});
cmd.command("set-typing-guard").argument("<seat>")
.option("--enabled <boolean>", "Compatibility: true selects hold; false selects off")
.option("--mode <mode>", "off, draft-aware, openrig-urgent, urgent-only, sends-only or hold; choose this or --enabled")
@@ -539,7 +493,7 @@ Examples:
cmd
.command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<seat> (native fork of that seat's conversation, by its session name), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--source <source>", "Source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<id> (native fork of the source conversation), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
@@ -679,7 +633,7 @@ identity remains unverified; a version number alone cannot clear it.
// Thin CLI over the daemon's SeatLifecycleService; refusals print message +
// guidance + match list exactly as the daemon named them.
const runLifecycleVerb = async (
path: "set-terminal-policy" | "set-model" | "set-cwd" | "set-effort" | "set-permissions" | "set-permissions-to" | "launch" | "launch-plan" | "continue" | "stop" | "clean" | "restart" | "restart-plan" | "restart-verify",
path: "set-terminal-policy" | "set-model" | "set-cwd" | "set-effort" | "set-permissions" | "launch" | "launch-plan" | "continue" | "stop" | "clean" | "restart" | "restart-plan" | "restart-verify",
seat: string,
body: Record<string, unknown>,
opts: { json?: boolean },
@@ -696,9 +650,8 @@ identity remains unverified; a version number alone cannot clear it.
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
// A Codex profile change runs the native profile check (up to 10 s) before answering.
: path === "set-permissions"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: (body as Record<string, unknown>)["codexProfile"] !== undefined ? 30_000 : 10_000 })
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
: await client.post<Record<string, unknown>>(route, body);
} catch (err) {
if ((path !== "launch" && path !== "launch-plan" && path !== "continue" && path !== "restart" && path !== "set-cwd") || !(err instanceof DaemonTimeoutError)) throw err;
@@ -778,45 +731,25 @@ identity remains unverified; a version number alone cannot clear it.
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--mode <mode>", "floor, full_bypass, inherit, or a Claude mode supported by the bound managed launch context")
.option("--rules <file>", "JSON allow/deny lists for this Claude seat; replaces its recorded rules without changing its mode")
.option("--codex-permissions <file>", "JSON sandbox/approval envelope for this Codex seat; replaces its recorded mode")
.option("--codex-profile <name>", "Codex config profile for this seat, or inherit to clear it; approval stays with the envelope or session")
.requiredOption("--reason <text>", "Reason for the audited future-launch selection")
.option("--operator <address>", "Operator recorded on the audit event when no session identity is present")
.option("--json", "JSON output for agents")
.description("Select native permissions for future managed launches; no relaunch or work-posture change")
.addHelpText("after", "\nUse inherit to clear this seat's explicit selection. Current native processes, history, rules and hooks remain unchanged. A later lifecycle action needs its own authorization.\n--rules takes a JSON object with allow and deny lists only. Use empty lists to clear the seat rules. It does not remove inherited grants or change the running process.\n--codex-permissions takes a JSON object: sandbox (workspace-write or read-only), approval (never, on-request or untrusted), and for workspace-write network_access, writable_roots (absolute paths), exclude_tmpdir_env_var and exclude_slash_tmp. It replaces a recorded mode; --mode replaces the envelope. Full access stays --mode full_bypass.\n--codex-profile reports an absent, changed or unchecked profile file without refusing; a profile that fails to load is refused.")
.action(async (seat: string, opts: { mode?: string; rules?: string; codexPermissions?: string; codexProfile?: string; reason: string; operator?: string; json?: boolean }) => {
if ([opts.mode, opts.rules, opts.codexPermissions, opts.codexProfile].filter(value => value !== undefined).length !== 1) {
console.error("Choose exactly one of --mode, --rules <file>, --codex-permissions <file> or --codex-profile <name>."); process.exitCode = 1; return;
.addHelpText("after", "\nUse inherit to clear this seat's explicit selection. Current native processes, history, rules and hooks remain unchanged. A later lifecycle action needs its own authorization.\n--rules takes a JSON object with allow and deny lists only. Use empty lists to clear the seat rules. It does not remove inherited grants or change the running process.")
.action(async (seat: string, opts: { mode?: string; rules?: string; reason: string; operator?: string; json?: boolean }) => {
if ((opts.mode === undefined) === (opts.rules === undefined)) {
console.error("Choose exactly one of --mode or --rules <file>."); process.exitCode = 1; return;
}
let rules: unknown;
if (opts.rules !== undefined) {
try { rules = JSON.parse(readFileSync(opts.rules, "utf8")); }
catch { console.error(`Cannot read JSON rules from ${opts.rules}; provide an allow/deny mapping.`); process.exitCode = 1; return; }
}
let codexPermissions: unknown;
if (opts.codexPermissions !== undefined) {
try { codexPermissions = JSON.parse(readFileSync(opts.codexPermissions, "utf8")); }
catch { console.error(`Cannot read JSON from ${opts.codexPermissions}; provide a sandbox/approval mapping.`); process.exitCode = 1; return; }
}
const choice = opts.rules !== undefined ? { rules } : codexPermissions !== undefined ? { codexPermissions }
: opts.codexProfile !== undefined ? { codexProfile: opts.codexProfile } : { mode: opts.mode };
await runLifecycleVerb("set-permissions", seat, { ...choice, reason: opts.reason, operator: opts.operator }, opts, data => {
await runLifecycleVerb("set-permissions", seat, { ...(opts.rules === undefined ? { mode: opts.mode } : { rules }), reason: opts.reason, operator: opts.operator }, opts, data => {
if (opts.rules !== undefined) {
console.log(`Claude tool rules ${data["changed"] === false ? "unchanged" : "recorded and audited"}.`);
console.log(String(data["effect"])); return;
}
if (codexPermissions !== undefined) {
const replaced = data["replaced"] as { mode?: { mode?: string } } | undefined;
console.log(`Codex permission envelope ${data["changed"] === false ? "unchanged" : "recorded and audited"}${replaced?.mode?.mode ? `; it replaces the recorded ${replaced.mode.mode} mode` : ""}.`);
console.log(String(data["effect"])); return;
}
if (opts.codexProfile !== undefined) {
const observed = data["observation"] as { file?: string; detail?: string; changedSinceRecorded?: boolean } | undefined;
console.log(`Codex profile: ${(data["to"] as string | null) ?? "inherit"}${data["changed"] === false ? " (unchanged)" : " (audited)"}`);
if (observed?.detail) console.log(`Profile file: ${observed.file ?? "unverified"}${observed.changedSinceRecorded ? ", changed since last recorded" : ""}. ${observed.detail}`);
console.log(String(data["effect"])); return;
}
const selection = data["to"] as { mode: string } | null;
console.log(`Permission mode: ${selection?.mode ?? "inherit"}${data["changed"] === false ? " (unchanged)" : " (audited)"}`);
console.log(String(data["effect"]));
@@ -862,49 +795,6 @@ Examples:
});
});
cmd
.command("set-permissions-to")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--to <person>", "The registered person who answers this seat's runner permission requests instead of the default person, such as mike@external")
.option("--clear", "Remove the override: the default person answers again")
.option("--deny", "Opt in to refusing every runner permission request on this seat")
.requiredOption("--reason <text>", "Audit reason recorded on the node.permissions_to_changed event")
.option("--operator <address>", "Operator recorded on the audit event (required outside a managed seat)")
.option("--json", "JSON output for agents")
.description("Choose who answers a runner seat's permission requests instead of the default person, or opt in to refusing them (audited)")
.addHelpText("after", `
A runner seat's agent can ask permission for a tool call. The runner sends the
request to OpenRig, and one person gets an ordinary ask with every option the agent
offered; the answer goes back to that one request. That person is the default person
(rig gateway default), unless this override names someone else. When no one can be
asked, OpenRig's default is to permit the request.
Only a registered person can be chosen (rig gateway human list). A seat address is
refused. --clear removes the override. --deny is the opt-in for a seat whose requests
should all be refused. See docs/reference/harness-runner-contract.md, section 7.
Examples:
rig seat set-permissions-to dev-runner@my-rig --to mike@external --reason "Mike approves tool calls"
rig seat set-permissions-to dev-runner@my-rig --deny --reason "high-stakes repo: refuse every request"
rig seat set-permissions-to dev-runner@my-rig --clear --reason "back to the default person" --json`)
.action(async (seat: string, opts: { to?: string; clear?: boolean; deny?: boolean; reason: string; operator?: string; json?: boolean }) => {
if ([Boolean(opts.clear), Boolean(opts.deny), opts.to !== undefined].filter(Boolean).length !== 1) {
console.error("Pass exactly one of --to <person>, --clear or --deny.");
process.exitCode = 1;
return;
}
const body = opts.clear ? { clear: true, reason: opts.reason, operator: opts.operator }
: opts.deny ? { deny: true, reason: opts.reason, operator: opts.operator }
: { to: opts.to, reason: opts.reason, operator: opts.operator };
await runLifecycleVerb("set-permissions-to", seat, body, opts, (data) => {
const s = data["seat"] as { logicalId?: string; rigName?: string } | undefined;
const name = `${s?.logicalId}@${s?.rigName}`;
const owner = (value: unknown) => value === "deny" ? "refused (--deny)" : typeof value === "string" ? value : "the default person";
console.log(data["changed"] === false
? `${name}'s permission requests already go to ${owner(data["to"])}. No change recorded.`
: `${name}'s permission requests: ${owner(data["from"])} -> ${owner(data["to"])} (audited).`);
});
});
cmd
.command("effort")
.argument("<seat>", "Canonical session name or logical seat ref")
@@ -972,10 +862,6 @@ resume/successor launch reads it at call time. Examples:
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
.option("--account-label <label>", "With --token-file, the selected account's label")
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
.option("--confirm-timeout <seconds>", "How long the successor has to confirm the handover (default 180)")
.option("--dry-run", "Show the plan (settings, what is cleared, costs) without changing anything")
@@ -998,9 +884,6 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
processes run until cutover; messages sent to the seat during the switch are
held, not typed, and need sending again afterwards.
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit" --dry-run
@@ -1008,11 +891,7 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat status dev-impl@my-rig # an open or interrupted switch shows here
rig seat set-runtime dev-impl@my-rig --recover`)
.action(async (seat: string, opts: { runtime?: string; reason?: string; model?: string; effort?: string; codexConfigProfile?: string;
permissionMode?: string; accountHome?: string; clearAccountHome?: boolean; tokenFile?: string; accountLabel?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.accountLabel !== undefined || opts.runtime === undefined)) {
console.error("Use --clear-account-home with --runtime and without --account-home, --token-file or --account-label.");
process.exitCode = 1; return;
}
permissionMode?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
const modes = [opts.confirm !== undefined, opts.recover === true, opts.runtime !== undefined].filter(Boolean).length;
if (modes !== 1) {
console.error("Pass exactly one of --runtime <runtime> (with --reason), --confirm <switch-id> (with --note), or --recover.");
@@ -1044,42 +923,19 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
const client = deps.clientFactory(getDaemonUrl(daemon));
const base = `/api/seat/set-runtime/${encodeURIComponent(seat)}`;
let res;
let switchRequested = false;
try {
res = opts.confirm !== undefined
? await client.post<Record<string, unknown>>(`${base}/confirm`, { switchId: opts.confirm, note: opts.note })
: opts.recover
? await client.post<Record<string, unknown>>(`${base}/recover`, {}, { timeoutMs: 60_000 })
: await (async () => {
const body = {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(opts.accountHome !== undefined ? { accountHome: opts.accountHome } : {}),
...(opts.clearAccountHome ? { clearAccountHome: true } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.accountLabel ?? "selected-account" } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
};
if (opts.accountHome !== undefined || opts.clearAccountHome) {
const preview = await client.post<Record<string, unknown>>(base, { ...body, dryRun: true }, { timeoutMs: 30_000 });
const token = opts.tokenFile ? { label: opts.accountLabel ?? "selected-account", tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
return { status: 409, data: { ok: false, code: "account_home_switch_unavailable",
message: preview.data["message"] ?? "The daemon did not acknowledge the selected account home and token. No switch was requested." } };
}
if (opts.dryRun) return preview;
}
switchRequested = true;
return client.post<Record<string, unknown>>(base, body,
{ timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
})();
: await client.post<Record<string, unknown>>(base, {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
}, { timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
} catch (err) {
if ((opts.accountHome !== undefined || opts.clearAccountHome) && !switchRequested) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (!(err instanceof DaemonTimeoutError) || opts.dryRun) throw err;
const unknown = { ok: false as const, code: "runtime_switch_outcome_unknown", status: "unknown",
message: "The CLI stopped waiting; the daemon may still be switching the seat.",
@@ -1110,26 +966,10 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
console.log(`${name} already runs ${String(data["to"])}. Nothing changed.`);
return;
}
if (data["firstStart"] === true) {
const from = String(data["from"]), to = String(data["to"]);
console.log(data["dryRun"]
? `${name} has never started: its saved runtime would change from ${from} to ${to}, and its first start would use it. Nothing changes until you run it without --dry-run.`
: `${name} has never started, so nothing was switched (no switch id): its saved runtime changed from ${from} to ${to}, and its first start uses it.`);
const permission = data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.source === "explicit" ? `${permission.mode} (as given)` : "the seat's member and rig policy"}`);
const dropped = (data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${from}: ${dropped.map((d) => `${d.setting} ${d.value}`).join("; ")}. Its first start takes the rest from its agent spec for ${to}.`);
return;
}
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
else if (data["recoveryPending"] === true) console.log(`Recovery is still pending for ${name} (switch ${String(data["switchId"])}); the runtime switch is not complete.`);
else console.log(`Switched ${name} from ${String(data["from"])} to ${String(data["to"])} (switch ${String(data["switchId"])}).`);
if (data["accountHome"] === null) console.log("Selected account home: configured defaults (member/rig, then daemon, then native)");
else if (data["accountHome"]) console.log(`Selected account home: ${String(data["accountHome"])}`);
const accountToken = data["accountToken"] as { label: string; tokenFile: string } | undefined;
if (accountToken) console.log(`Claude token reference: ${accountToken.label}, read from ${accountToken.tokenFile} at launch`);
console.log(`Settings on ${String(data["to"])}:`);
for (const [key, title] of [["model", "model"], ["effort", "effort"], ["advisor", "advisor"], ["codexConfigProfile", "Codex profile"]] as const) {
const setting = settings[key];
@@ -1383,8 +1223,6 @@ session is refused (use rig unclaim). Stopping ends that agent's session and any
work in progress: check it first (rig capture <seat>) and stop only a seat you
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
a fresh occupant there; it does not resume the stopped conversation.
A community harness stop closes its managed pane; its native processes or compute
may outlive it. Inspect their own lifecycle receipts.
An intentionally stopped seat does not degrade an otherwise running rig.
Running counts still exclude the stopped seat; stopping every seat leaves the
rig stopped. Examples:
@@ -1395,7 +1233,6 @@ rig stopped. Examples:
const s = data["seat"] as { logicalId?: string; rigName?: string } | undefined;
console.log(`Stopped ${String(data["sessionName"])} (seat ${s?.logicalId}@${s?.rigName}).`);
console.log("Session marked exited, binding cleared; siblings untouched. Relaunch via the normal launch surface.");
if (data["paneOnly"]) console.log("Only the community seat viewer was closed. Native execution, storage and compute may remain; inspect the selected environment or harness lifecycle.");
});
});
@@ -1570,7 +1407,7 @@ export function handoverCommand(depsOverride?: SeatDeps): Command {
};
return new Command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Successor source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<seat> (native fork of that seat's conversation, by its session name), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--source <source>", "Successor source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<id> (native fork of the source conversation), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
@@ -1578,8 +1415,8 @@ export function handoverCommand(depsOverride?: SeatDeps): Command {
.description("Hand a seat to a successor: create -> deliver context -> verify continuity -> rebind")
.addHelpText("after", `
All four sources execute (OPR.0.5.5.5): fresh delivers a captured restore
packet; discovered adopts an operator-prepared live session; fork:<seat> launches
a NATIVE FORK of that seat's conversation (the successor carries the incumbent
packet; discovered adopts an operator-prepared live session; fork:<id> launches
a NATIVE FORK of the source conversation (the successor carries the incumbent
context from its first byte); rebuild launches fresh and primes from the seat's
durable artifact chain, recording exactly what it found. A source that cannot
proceed (e.g. fork with no discoverable native id) refuses honestly before any
+41 -38
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl, fetchSelfHostId, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -8,18 +8,18 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry }
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigUrl } from "../openrig-compat.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
// The reply follows the guarded paste/Enter (and optional verification), not acceptance of the request.
// Successful sends can exceed the ordinary client's 5 s read budget. Keep a finite response wait on both
// local and remote paths; a longer send or lost response remains unconfirmed and is never retried here.
const SEND_RESPONSE_TIMEOUT_MS = 60_000;
const UNCONFIRMED_SEND_GUIDANCE = "A busy agent may hold delivered input off-screen, so absence from a pane capture does not prove non-delivery. " +
"Wait for recipient acknowledgment or reconcile with the recipient before resending; another send or queue item risks duplicate work.";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
// A cross-host send waits for the remote to deliver, and the remote has no fixed deadline of its own (its tmux
// calls run without a timeout), so no client budget is exact. This one sits well above a normal send, and above
// fetch's 10 s connect timeout, so a host that never accepts the connection still reads as unreachable. A send
// that outlasts it reads as unconfirmed, never as failed.
const CROSS_HOST_SEND_TIMEOUT_MS = 30_000;
/** The shared composer glyph set (Claude `❯`, Codex `›`, Codex 0.153 `»`) from the daemon's
* composer-prompts surface, so the CLI staged check can never disagree with the daemon guard. */
@@ -57,9 +57,6 @@ export interface EnvelopeMeta {
/** GHOST-STAGE (g): the SENDER's atom-B occupant generation-uuid, stamped ONCE at transport.
* ABSENT ⇒ UNKNOWN → the render OMITS the suffix (never forges). Twin of pane-envelope's field. */
genUuid?: string;
/** Twin of pane-envelope's field: the row a person's (@external) message delivers, named in the
* reply hint with --reply-to so the answer joins its Slack thread. ABSENT ⇒ the plain hint. */
replyToQitem?: string;
}
/** The To-line projection + anti-storm scale (header-alone distinguishability). */
@@ -101,7 +98,7 @@ export function wrapSendBody(
header.push(`Sent: ${renderShortStamp(meta.stampISO)}${genSuffix}`);
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --summary "<one line>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -111,12 +108,13 @@ export function wrapSendBody(
* fact / consequence / action shape (`daemon-lifecycle.ts:61-73`).
*
* Deliberately NOT `daemonNotRunningError()`: that helper's text ("Daemon not
* running." + restart advice) is the probe-derived claim that was
* running." + restart advice) is the probe-derived claim qitem-c113bd41
* removed. The SHAPE is reused; the TEXT is not.
*
* A connection error can follow delivery; only positive no-connect evidence
* earns a non-delivery claim. Share that distinction with remote sends. The
* diagnostic action asserts no daemon state: with an env URL set,
* A `DaemonConnectionError` proves the resolved target was UNREACHABLE — not
* why. Daemon down, wrong port, wrong host, firewall, and a wedged event loop
* are all live explanations, so the action stays diagnostic and asserts no
* daemon state. It also refuses to oversell `rig status`: with an env URL set,
* that command's own probe can report `stopped` for a mere timeout
* (`daemon-lifecycle.ts:575-585`) — the same false-stopped class this slice
* exists to remove — so the copy names that limitation instead of hiding it,
@@ -133,14 +131,16 @@ function printTransportFailure(err: DaemonConnectionError, opts?: { json?: boole
// (daemon-lifecycle.ts) so an agent on the --json path gets a parseable record
// instead of empty stdout plus human prose on stderr.
const fact = err.message;
// A timeout or lost connection can hide a completed send. Use the existing
// remote-request classification rather than treating every non-timeout as unsent.
const outcomeUnknown = requestMayHaveArrived(err);
const consequence = outcomeUnknown
// B8-2 (shape 73ee4b25): a TIMEOUT is delivery-UNCONFIRMED — the daemon may have
// received AND delivered (two seats proved delivery-after-"not sent" live). Only a
// hard connection failure earns the "was not sent" consequence.
const timedOut = err instanceof DaemonTimeoutError;
const consequence = timedOut
? "Delivery UNCONFIRMED — the daemon may have received and delivered the message."
: "The message was not sent.";
const action = outcomeUnknown
? "Reconcile by EFFECT before any resend: check the target (rig capture <session>). " + UNCONFIRMED_SEND_GUIDANCE
const action = timedOut
? "Reconcile by EFFECT before any resend: check the pane/target for the message (rig capture <session>). " +
"A resend without checking risks a duplicate. Then 'rig daemon status' for the probe picture."
: "Inspect the configured target with 'rig status'; a failed health probe does not prove the daemon is stopped. " +
"If the target is wrong, check OPENRIG_URL / RIGGED_URL or daemon.host + daemon.port. " +
"If the daemon is confirmed stopped, run 'rig daemon start'.";
@@ -168,7 +168,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
return true;
}
/** The LOCAL send target. The status probe is advisory,
/** qitem-c113bd41 — the LOCAL send target. The status probe is advisory,
* never authoritative: a busy/wedged daemon fails the probe while the
* transport would succeed (the false-daemon-down incident). Resolution:
* the configured env alias FIRST (exact string, custom port preserved;
@@ -186,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
* keeps the single-seat and fan-out callers from having to sequence it
* identically in two places. */
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
const envUrl = readOpenRigUrl();
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (envUrl) return envUrl;
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
@@ -238,7 +238,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--rig <name>", "Send to every seat in a rig (fan-out, per-recipient results)")
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
@@ -282,11 +282,7 @@ determined (unknown / missing / stale telemetry) the send PROCEEDS with an
advisory note — telemetry is advisory, not authority over whether agents can
communicate. Use --wait-for-idle to send only after explicit idle evidence. Use
--verify to confirm the message appeared in the pane only; it is not agent
acknowledgement. Local and HTTP sends wait up to 60 s for the response, plus any
--wait-for-idle duration. A timeout or missing pane text does not prove the
message was not delivered: a busy agent can hold input off-screen. Reconcile
with the recipient before resending or filing the same work as a queue item.
With transport.verify_input_box on, a Claude Code or Codex send
acknowledgement. With transport.verify_input_box on, a Claude Code or Codex send
reads verified only when the seat was proven idle before the paste (its screen
matched an idle layout recorded from Claude Code 2.1.296 or Codex 0.160.0; a
screen that names another version reads unconfirmed, and a matching layout that
@@ -485,7 +481,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
return;
}
// The status probe is ADVISORY (target discovery
// qitem-c113bd41 — the status probe is ADVISORY (target discovery
// only); the actual transport is authoritative. A probe-timeout or
// running/unhealthy verdict no longer refuses the send. ff13bcdf —
// the resolver takes that probe lazily, and skips it entirely when an
@@ -883,7 +879,7 @@ async function runCrossHostSend(
* sender verbatim — honest provenance; unknown on the remote it degrades to
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
* locally synthesized). Deadline: SEND_RESPONSE_TIMEOUT_MS, plus waitForIdleMs
* locally synthesized). Deadline: CROSS_HOST_SEND_TIMEOUT_MS, plus waitForIdleMs
* when --wait-for-idle. A send that may have arrived reads as unconfirmed.
*
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer
@@ -921,7 +917,7 @@ async function runHttpHostSend(
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
...(opts.urgent ? { urgent: true } : {}),
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS });
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
// A send that may have reached the remote is unconfirmed, not unreachable: it may have been delivered, and a
// resend without checking risks a duplicate.
@@ -929,7 +925,7 @@ async function runHttpHostSend(
const shown = unconfirmed ? { ...result, failedStep: "remote-outcome-unknown" as const } : result;
const unconfirmedGuidance = {
consequence: "Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.",
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. ${UNCONFIRMED_SEND_GUIDANCE}`,
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. A resend without checking risks a duplicate.`,
};
if (opts.json) {
@@ -1022,7 +1018,7 @@ async function runFanOutSend(params: {
}): Promise<void> {
const { toList, pod, rig, message, opts, deps, seatSender } = params;
// The same advisory-probe/transport-authoritative contract
// qitem-c113bd41 — same advisory-probe/transport-authoritative contract
// as the single-seat path, including ff13bcdf's lazy probe (see
// resolveLocalDaemonUrl).
const client = deps.clientFactory(await resolveLocalDaemonUrl(deps));
@@ -1165,11 +1161,18 @@ function parseWaitForIdleMs(value: string | undefined): number | undefined | nul
return Math.ceil(seconds * 1000);
}
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs: number; headers?: Record<string, string> } {
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
const headers = terminalAuthHeaders();
const hasHeaders = Object.keys(headers).length > 0;
if (!waitOptions && !hasHeaders) return undefined;
return {
timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS,
...(waitOptions ?? {}),
...(hasHeaders ? { headers } : {}),
};
}
+2 -26
View File
@@ -9,7 +9,7 @@ import { parseDocument } from "yaml";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { checkClaudeAuth, checkCodexAuth } from "../provider-auth.js";
import { describeSkillRouterFootprint } from "@openrig/daemon/router-line";
@@ -537,14 +537,7 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
fixHint: failure.fix,
});
} else {
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
if (capture.status === "fail") {
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
steps.push({ id: "tmux_install", status: "fail", message: failure.message, reason: failure.reason, fixHint: failure.fix });
} else {
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
// 3. Herdr is the default view provider. Leave optional cmux installations untouched.
@@ -928,24 +921,8 @@ tmux scrollback:
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
// the primary surface for the new-operator sequence (status/doctor only
// HINT back to it; the durable reference is docs/reference/getting-started.md).
// A failed doctor check also leaves setup not ready, so it's named below. So is the port check's warning, though setup
// stays ready: the configured port's daemon isn't confirmed as this user's, such as another user's daemon. Provider
// checks are the setup steps themselves, reused, so they aren't listed twice.
const stepIds = new Set(result.steps.map(step => step.id));
const doctorAttention = result.verification?.checks.filter(check => !stepIds.has(check.name)
&& (check.status === "fail" || (check.name === "port" && check.status === "warn"))) ?? [];
const listChecks = () => {
for (const check of doctorAttention) {
console.log(` ${check.name}: ${check.message}`);
if (check.fix) console.log(` Fix: ${check.fix}`);
}
};
if (result.ready) {
console.log("\nSetup complete.\n");
if (doctorAttention.length) {
console.log("Some steps need attention:");
listChecks();
}
} else {
console.log("\nSome steps need attention:");
if (opts.dryRun) console.log("Plan only: no setup changes were made; readiness was not checked.");
@@ -953,7 +930,6 @@ tmux scrollback:
console.log(` ${step.id}: ${step.message}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
listChecks();
console.log("Only the harnesses selected for your project need a login; an unused harness does not (rerun with --providers to leave it out).");
console.log("Run `rig doctor` to recheck the system and provider authentication.");
}
+1 -1
View File
@@ -175,7 +175,7 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const wait = opts.waitForIdle !== undefined ? Number(opts.waitForIdle) : undefined;
const res = opts.apply
? await client.post<SeatApplyResponse>("/api/skills/seat/apply", { session, ...(wait && wait > 0 ? { waitForIdleMs: wait } : {}), ...(opts.resend ? { resend: true } : {}) }, wait && wait > 0 ? { timeoutMs: wait + 5_000 } : undefined)
? await client.post<SeatApplyResponse>("/api/skills/seat/apply", { session, ...(wait && wait > 0 ? { waitForIdleMs: wait } : {}), ...(opts.resend ? { resend: true } : {}) })
: await client.get<SeatInspection>(`/api/skills/seat?session=${encodeURIComponent(session)}`);
const data = res.data as SeatApplyResponse & SeatInspection;
const failure = (res.data as { error?: string }).error;
+2 -73
View File
@@ -112,53 +112,10 @@ const RETIRED_TEACHING =
const MANIFEST_FIRST_STEP =
"`rig slack manifest --url` prints a link that creates your own Slack app from OpenRig's manifest (see `rig slack manifest --help`)";
// #192: a saved map change reaches the running connector at a daemon restart. Disable then enable rewires too, but
// enabling marks alerts still waiting to be posted as already seen, so it isn't the advice.
// #192: a saved config change reaches the running connector when it next rewires.
const CHANNEL_MAP_APPLY =
"Invite the app to every mapped channel, then `rig slack verify`. A running connector picks up the change " +
"at a daemon restart (`rig daemon stop`, then `rig daemon start`). Don't use `rig slack disable` then " +
"`rig slack enable` for it: enabling marks alerts still waiting to be posted as already seen.";
const POST_OPEN_ASKS = "`rig slack post-open-asks`";
type OpenAskOutcome = "posted" | "already-posted" | "queued" | "held" | "below-floor" | "no-longer-active" | "superseded"
| "recipient-changed" | "state-unavailable" | "transport-failed" | "uncertain";
interface OpenAsk { qitemId: string; recipient: string; summary: string | null; outcome: OpenAskOutcome; detail?: string }
interface PostOpenAsksResponse { ok: boolean; asks: OpenAsk[] }
const OUTCOME_LABEL: Record<OpenAskOutcome, string> = {
posted: "posted",
"already-posted": "already posted",
queued: "queued, awaiting receipt",
held: "held",
"below-floor": "below the posting floor",
"no-longer-active": "no longer active",
superseded: "superseded",
"recipient-changed": "recipient changed",
"state-unavailable": "state unavailable",
"transport-failed": "transport failed",
uncertain: "uncertain",
};
/** #1679: one count line, one line per ask, then what to do about the outcomes that need it. */
export function describeOpenAsks(asks: OpenAsk[]): string[] {
if (asks.length === 0) return ["No open asks to show."];
const counts = new Map<OpenAskOutcome, number>();
for (const ask of asks) counts.set(ask.outcome, (counts.get(ask.outcome) ?? 0) + 1);
const lines = [`${asks.length} open ask(s): ${[...counts].map(([outcome, n]) => `${n} ${OUTCOME_LABEL[outcome]}`).join(", ")}`];
for (const ask of asks) {
const detail = ask.detail ? ` (${ask.detail})` : "";
lines.push(` ${OUTCOME_LABEL[ask.outcome]}: ${ask.qitemId} for ${ask.recipient}: ${ask.summary ?? "(no summary)"}${detail}`);
}
if (counts.has("queued") || counts.has("uncertain")) {
lines.push(`Queued or uncertain asks have no Slack receipt yet. Run ${POST_OPEN_ASKS} again later to see where they landed; it reuses each ask's decision rather than offering it again.`);
}
if (counts.has("state-unavailable")) {
lines.push(`No new offer was made for an ask whose state couldn't be read; its detail says what couldn't be read and what is known. Run ${POST_OPEN_ASKS} again once that reads, and the same ask continues.`);
}
if (counts.has("transport-failed")) lines.push("A transport failure is on the ask's row, and `rig queue undelivered` lists it for the seat that asked.");
return lines;
}
"when it next rewires (`rig slack disable` then `rig slack enable`, or a daemon restart).";
function resolveSecrets(surface: SlackSurface, cfg: SlackConnectorConfig): { bot: string | null; app: string | null } {
const envFile = cfg.secretsEnvFile ?? undefined;
@@ -186,7 +143,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (opts) => {
@@ -201,7 +157,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
sourceLabel: opts.sourceLabel ?? cur.sourceLabel,
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
};
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
const result = await runChannelOperation({
@@ -516,38 +471,12 @@ export function slackCommand(deps: SlackDeps = {}): Command {
const res = await clientFactory().post<{ ok: boolean; seeded: number; onlineStatus: string }>("/api/gateway/slack/enable", { reason: opts.reason, actor: resolveSenderSession() ?? opts.actor ?? SENDER_FALLBACK });
if (res.status !== 200 || res.data.ok !== true) throw new Error(`daemon refused enable (HTTP ${res.status}): ${JSON.stringify(res.data)}`);
log(res.data.onlineStatus);
if (res.data.seeded > 0) log(`To show the asks still open in Slack, run ${POST_OPEN_ASKS}.`);
} catch (e) {
log(`✗ enable failed: ${(e as Error).message}`);
process.exitCode = 1;
}
});
cmd
.command("post-open-asks")
.description("Show in Slack the asks still open that enable marked as seen, through ordinary delivery, and say what became of each")
.option("--reason <reason>", "Reason recorded with the action")
.option("--wait <seconds>", "How long to wait for Slack receipts before reporting the rest as queued (0-60)", "10")
.option("--actor <actor>", "Named operator when outside a managed seat")
.option("--json", "Print the daemon's result as JSON")
.action(async (opts) => {
try {
const waitSeconds = Number(opts.wait);
if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 60) throw new Error(`--wait takes 0 to 60 seconds, not ${opts.wait}`);
const res = await clientFactory().post<PostOpenAsksResponse>("/api/gateway/slack/post-open-asks",
{ reason: opts.reason, waitSeconds, actor: resolveSenderSession() ?? opts.actor ?? SENDER_FALLBACK },
{ timeoutMs: (waitSeconds + 30) * 1000 });
if (res.status !== 200 || res.data.ok !== true) {
throw new Error((res.data as { message?: string }).message ?? `daemon refused (HTTP ${res.status}): ${JSON.stringify(res.data)}`);
}
if (opts.json) { log(JSON.stringify(res.data, null, 2)); return; }
for (const line of describeOpenAsks(res.data.asks)) log(line);
} catch (e) {
log(`✗ post-open-asks failed: ${(e as Error).message}`);
process.exitCode = 1;
}
});
cmd
.command("disable")
.description("Disable the connector (the daemon rewires to an inert delivery path)")
+10 -35
View File
@@ -20,14 +20,6 @@ import { daemonMayAnswer } from "../daemon-reachability.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const KNOWN_SPEC_KINDS = ["rig", "agent", "workflow"] as const;
function warnIfUnknownKind(kind?: string): void {
if (kind !== undefined && !KNOWN_SPEC_KINDS.includes(kind as (typeof KNOWN_SPEC_KINDS)[number])) {
console.error(`Warning: unrecognized --kind '${kind}'. Supported kinds: ${KNOWN_SPEC_KINDS.join(", ")}`);
}
}
interface LibraryEntry {
id: string;
kind: "rig" | "agent" | "workflow";
@@ -155,16 +147,10 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: string },
opts?: { kind?: LibraryEntry["kind"] },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
// Try exact ID match first
const byId = entries.find((e) => e.id === nameOrId);
@@ -233,16 +219,11 @@ Examples:
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const url = opts.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
const entries =
opts.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
@@ -250,11 +231,7 @@ Examples:
}
if (entries.length === 0) {
if (opts.kind !== undefined) {
console.log(opts.kind ? `No ${opts.kind} specs found` : "No specs found");
} else {
console.log("No specs in library. Add specs with: rig specs add <path>");
}
console.log("No specs in library. Add specs with: rig specs add <path>");
return;
}
@@ -273,11 +250,10 @@ Examples:
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -302,11 +278,10 @@ Examples:
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
const review = res.data;
const teamChoice = entry.sourceType === "builtin" && entry.kind === "rig" && ["starter", "factory"].includes(entry.name)
+5 -10
View File
@@ -10,7 +10,6 @@ import {
getDaemonUrl,
startDaemon,
waitForKernelReady,
skippedKernelMessage,
type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js";
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
@@ -191,13 +190,6 @@ Examples:
When the daemon is restoring a kernel a reboot left down, rig start goes on to
your other rigs. Exit 0 then does not mean the kernel is ready (check rig
status); a restore that returned without a ready kernel exits 1.
--last restores a rig unless every seat it launched was stopped on purpose. A
rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or
were lost without rig down, counts as last running: run rig down <rig> once
(its id when names are shared) and --last keeps it down. --last never selects
the kernel; under --no-kernel its automatic restore is skipped too, so bring it
back with rig up kernel --existing.
`);
const getDepsF = (): StartDeps =>
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -294,8 +286,11 @@ back with rig up kernel --existing.
if (!opts.json) console.log("Continuing with your other rigs.");
} else if (!kernelResult.ok) {
if (kernelResult.kernelState === "skipped") {
// This command does not restore a kernel the daemon skipped; say why, and the way back.
if (!opts.json) console.log(skippedKernelMessage(kernelResult.detail));
// The daemon also skips its boot when a kernel rig already exists (kernel-boot.ts).
// That rig need not be running, after a reboot it is not, and this command does not restore it.
if (!opts.json) console.log(kernelResult.detail === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.");
} else {
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
+1 -4
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { formatDaemonHostForUrl } from "../client.js";
import { readOpenRigUrl } from "../openrig-compat.js";
export interface StartupProofDeps {
env?: NodeJS.ProcessEnv;
@@ -34,9 +33,7 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
const env = deps.env ?? process.env;
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
// normalize through the shared reader like every other OPENRIG_URL reader.
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
if (!baseUrl || !token) {
+1 -3
View File
@@ -20,9 +20,7 @@ export interface StatusDeps {
function formatSnapshotAge(snapshotAt: string | null): string {
if (!snapshotAt) return "none";
const now = Date.now();
// Snapshot times are SQLite `datetime('now')` stamps: UTC written without a zone, which Date reads as local time.
const then = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(snapshotAt)
? `${snapshotAt.replace(" ", "T")}Z` : snapshotAt);
const then = new Date(snapshotAt).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return "just now";
+13 -74
View File
@@ -1,11 +1,10 @@
// OPR.0.4.6.02 C3 — the `rig terminal` command family (the TUI-1 opaque verb +
// the operator's terminal-provider-ride entry). The subcommands read
// the operator's terminal-provider-ride entry). Three subcommands, all hitting
// the ONE canonical daemon composer (`/api/terminal/...`):
//
// rig terminal open <view> [--provider herdr|cmux|rex] [--json]
// rig terminal views [--json]
// rig terminal status [--provider herdr|cmux|rex] [--json]
// rig terminal attach <session> [--read-only]
//
// `<view>` resolves daemon-side: a rig name (per-rig derived) | `mission:<id>` |
// `slice:<id>` (derived) | a saved-view id. The result is the ONE shared
@@ -18,34 +17,22 @@
// exit 0 unless the daemon is unreachable.
import { Command } from "commander";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
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";
import { assertLocalTmux, attachTmux, isLocalTmuxHost, readTmuxServerArgs, type TmuxAttachment } from "../tmux-attachment.js";
export interface TerminalDeps extends StatusDeps {
windowDeps?: WindowDeps;
attachTmux?: (target: TmuxAttachment) => Promise<number>;
}
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
}
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
export interface OpenViewResult {
workspaceId?: string;
following?: { id: string; enabled: boolean; state: "following" | "empty" | "retained" | "disabled";
maxTiles: number; tiles: Array<{ key: string; seat: string; departedAt?: number; reason?: string }>;
events: string[]; overflow: Array<{ key: string; seat: string; reason: string }>; nextAt?: number };
provider: string;
session?: string;
ok: boolean;
opened: string[];
absent: { seat: string; host: string | null; reason: string }[];
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
degraded: { seat: string; host: string; reason: string }[];
pages: number;
error?: string;
code?: string;
@@ -89,19 +76,14 @@ function humanOpen(r: OpenViewResult): string {
? `Terminal window requested. Reused ${target} workspace ${r.reusedWorkspace.id} for view ${JSON.stringify(r.reusedWorkspace.view)}.`
: tiled > 0
? `${r.window ? "Terminal window requested. " : ""}Prepared ${tiled} tile(s) in ${target}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
: `No tiles ${r.degraded.some(d => d.code === "pane_unconfirmed") ? "confirmed" : "opened"} in ${target}.`,
: `No tiles opened in ${target}.`,
);
if (r.window) lines.push(` Terminal: ${r.window.app} (${r.window.surface}).`);
if (r.space) lines.push(` ${r.provider} session: ${JSON.stringify(r.space.label)} (${r.space.id}).`);
if (r.error && r.code !== "terminal_window_failed") lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
if (r.following) {
lines.push(` Following: ${r.following.state}; tile limit ${r.following.maxTiles}.`);
for (const tile of r.following.tiles) if (tile.reason) lines.push(` ${tile.seat}: ${tile.reason}`);
for (const hidden of r.following.overflow) lines.push(` ${hidden.seat}: ${hidden.reason}`);
}
for (const seat of r.opened) lines.push(` ● ${seat}`);
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — ${d.code === "pane_unconfirmed" ? "unconfirmed" : "skipped"} (${d.host}): ${d.reason}`);
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
for (const n of r.notes ?? []) lines.push(` note: ${n}`);
return lines.join("\n");
}
@@ -113,7 +95,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
} else {
console.log(humanOpen(r));
}
if (status >= 400 || (r.following ? !r.ok : r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
if (status >= 400 || (r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
process.exitCode = status >= 500 ? 2 : 1;
}
}
@@ -131,43 +113,27 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
cmd
.command("open")
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
.option("--unpin <seats...>", "Remove pins from these following tiles")
.option("--suppress <seats...>", "Temporarily hide these following tiles; agents keep running")
.option("--unsuppress <seats...>", "Show these following members again")
.option("--suppression-ms <ms>", "Suppression duration, default 60000, at most 86400000", Number)
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string }) => {
const deps = getDeps();
const session = opts.session?.trim();
const { maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } = opts;
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
await withClient(deps, async (client) => {
// Herdr's control socket can answer with no desktop client attached.
// The default requests a desktop; an explicit provider reuses its workspace.
if (following?.enabled !== false && (opts.window || !opts.provider)) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
if (opts.window || !opts.provider) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session);
printOpen(opts.json ?? false, result, 200);
return;
}
let expectedPlan = opts.expectedPlan;
if (session !== undefined) {
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider ?? "herdr")}&session=${encodeURIComponent(session)}`);
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider)}&session=${encodeURIComponent(session)}`);
if (preview.status >= 400 || preview.data.session !== session || typeof preview.data.planId !== "string") {
printResult(opts.json ?? false, { error: preview.data.error ?? "The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.", code: "terminal_session_unconfirmed" }, preview.status >= 400 ? preview.status : 409); return;
}
@@ -177,8 +143,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
expectedPlan = preview.data.planId;
}
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}), ...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) };
const res = await client.post<OpenViewResult>(following ? "/api/terminal/follow" : "/api/terminal/open",
{ ...body, ...following }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS, ...(following ? { headers: terminalAuthHeaders() } : {}) });
const res = await client.post<OpenViewResult>("/api/terminal/open", body, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
if (session !== undefined && res.status < 400 && res.data?.session !== session) {
printResult(opts.json ?? false, { ...res.data, ok: false, code: "terminal_session_unconfirmed",
error: "The open response did not confirm the requested Herdr session. Its outcome is unknown; inspect that session before retrying." }, 409); return;
@@ -225,31 +190,5 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
});
});
cmd.command("attach <session>")
.description("Attach this terminal to a session on the selected daemon's tmux server")
.option("--read-only", "View the session without sending input")
.action(async (session: string, opts: { readOnly?: boolean }) => {
const deps = getDeps();
await withClient(deps, async client => {
try {
if (client.baseUrl && !isLocalTmuxHost(client.baseUrl)) {
throw new Error("Terminal attachment uses local tmux; run this command on the selected daemon's machine.");
}
const response = await client.get<{ tmux?: { mode?: "inherit" | "isolated"; serverArgs?: unknown } }>("/api/terminal/status");
if (response.status >= 400) throw new Error(`Cannot read the tmux server (HTTP ${response.status}).`);
if (response.data.tmux?.serverArgs === undefined) {
throw new Error("This daemon cannot report its tmux server. Update and restart it before using terminal attach.");
}
await assertLocalTmux(client, response.data.tmux.mode);
process.exitCode = await (deps.attachTmux ?? attachTmux)({
session, serverArgs: readTmuxServerArgs(response.data.tmux.serverArgs), readOnly: opts.readOnly ?? false,
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
});
});
return cmd;
}
+1 -1
View File
@@ -76,7 +76,7 @@ OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 — relaunch it to restore scrollback.
const requestedLines = Number.isNaN(tailLines) || tailLines < 1 ? 50 : tailLines;
const apiPath = useGrep
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${requestedLines}`;
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
// transcript routes — the SAME paths the local path builds, origin
+2 -3
View File
@@ -6,7 +6,6 @@ import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { openMissionControl, USAGE_LINES, type FrontDoorIo } from "../front-door.js";
import { sharedTuiTarget, attachSharedTui } from "../shared-tui.js";
import type { TmuxAttachment } from "../tmux-attachment.js";
/** The registry-entry shape `rig tui commands` serializes (REGISTRY I2, ruling 64f1dbdf).
* Structural mirror of the TUI's CommandEntry data fields (functions are not serialized). */
@@ -39,8 +38,8 @@ async function loadRegistryFromDist(baseDir: string): Promise<TuiCommandEntry[]>
export function tuiCommand(io: FrontDoorIo & {
loadRegistry?: () => Promise<TuiCommandEntry[]>;
sharedTarget?: () => Promise<TmuxAttachment>;
attachShared?: (target: TmuxAttachment) => Promise<number>;
sharedTarget?: () => Promise<string>;
attachShared?: (target: string) => Promise<number>;
} = {}): Command {
const cmd = new Command("tui")
.description("open the status dashboard, not agent chat (--shared is the OpenRig view fallback)")
+1 -2
View File
@@ -161,8 +161,7 @@ function whoamiPeersBound(): number {
type TmuxExecFn = (cmd: string) => string;
// tmux's stderr (an unset @rigged_* option, no server) rides the thrown error; callers fall through.
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
function buildPartialWhoamiResult(source: { nodeId?: string; sessionName?: string }): Record<string, unknown> {
return {
+2 -11
View File
@@ -87,10 +87,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
.description(
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
)
.option(
"--kind <kind>",
"Workspace kind to validate against: user | project | knowledge | lab | delivery",
)
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
.option("--no-recursive", "Do not descend into subdirectories")
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
@@ -128,13 +125,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
maxFiles,
});
if (res.status >= 400) {
if (opts.json) {
console.error(JSON.stringify(res.data, null, 2));
} else {
const data = res.data as { message?: string; error?: string } | null | undefined;
const msg = data?.message ?? data?.error ?? `Validate failed (HTTP ${res.status})`;
console.error(msg);
}
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
+2 -15
View File
@@ -165,8 +165,6 @@ export interface RiggedConfig {
// `rig config get/set terminal.status_bar` surface + twin parity.
terminal: {
statusBar: boolean;
/** Applied at daemon startup; a live previous server is retained until drained. */
tmuxServer: "inherit" | "isolated";
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
historyLimit: number;
inheritTmuxEnvironment: boolean;
@@ -357,7 +355,6 @@ const DEFAULTS = {
// OPR.0.4.6.02 S1 — inner-tmux status bar OFF at launch by default.
terminal: {
statusBar: false,
tmuxServer: "inherit",
historyLimit: 10000,
inheritTmuxEnvironment: false,
},
@@ -507,7 +504,6 @@ export const VALID_KEYS = [
// boolean (default off), lockstep with the daemon settings-store twin
// (the parity test pins both). Flip is future-launches-only (BR-1).
"terminal.status_bar",
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
"terminal.history_limit",
"terminal.inherit_tmux_environment",
@@ -630,7 +626,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
@@ -729,7 +724,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
@@ -794,9 +788,8 @@ function assertNoRemovedContextSetting(fileConfig: Record<string, unknown>): voi
// settings-store.ts — the parity test pins both twins (host-registry twin
// discipline). Reserved segments cover key-level snake_case AND FILE-level
// camelCase toggle names so no host id shadows a flat toggle at either
// layer. The fixed prefix/suffix delimit a literal host id, including dots.
// Every other unknown key keeps the reject-loud behavior.
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_.-]+)\.enabled$/;
// layer. Every other unknown key keeps the reject-loud behavior.
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/;
export const FEED_HOST_RESERVED_SEGMENTS = new Set([
"action_required",
"actionRequired",
@@ -956,11 +949,6 @@ function percentageConstraint(key: string) {
}
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
@@ -1340,7 +1328,6 @@ export class ConfigStore {
},
terminal: {
statusBar: v("terminal.status_bar") as boolean,
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
historyLimit: v("terminal.history_limit") as number,
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
},
+1 -3
View File
@@ -72,9 +72,7 @@ export function resolveCrossHostTarget(
const suffix = rawTarget.slice(lastAt + 1);
const unregisteredHint = suffix.length > 0
? `no registered host '${suffix}' — if '${suffix}' is another machine's self-id (the address a cross-host message shows), `
+ `run \`rig host doctor <alias>\` for that machine's entry if it uses http (it learns the id), or set \`hostId: ${suffix}\` on that entry in hosts.yaml (any transport); `
+ `\`rig host ls\` lists the registered hosts`
? `no registered host '${suffix}' — if '${suffix}' was meant as a host, check \`rig host ls\``
: undefined;
if (suffix.length === 0 || base.length === 0) {
+10 -103
View File
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
import type { DaemonStartLock } from "./daemon-start-lock.js";
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
// instance is initialized, so a command that only talks to the daemon never loads it.
import type {
@@ -62,9 +62,6 @@ export interface DaemonStatus {
eventLoop?: DaemonEventLoopEvidence;
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
probeErrorCode?: string;
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
startPending?: { pid: number; since?: string };
}
/** "unknown": the process may exist but this shell can neither signal nor inspect it. */
@@ -75,18 +72,6 @@ export interface GetDaemonStatusOptions {
cleanupStaleState?: boolean;
}
/** True for `http://host:port` with an explicit, non-default port and no path, query or
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
function isPlainHttpOrigin(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
} catch {
return false;
}
}
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
@@ -140,16 +125,6 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
...causeCode,
};
}
// A refused probe under a live start reservation: a launch is in progress and nothing is listening yet.
if (status.startPending) {
const since = status.startPending.since ? ` since ${status.startPending.since}` : "";
return {
fact: `Daemon is starting (launch reserved by pid ${status.startPending.pid}${since}); it isn't listening yet.`,
consequence: "This command needs a responsive daemon.",
action: "Wait, then re-check with 'rig daemon status'.",
...causeCode,
};
}
// unverified, or running-but-unhealthy: we do NOT know it is down.
return {
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
@@ -381,7 +356,7 @@ export function resolveCliBaseDir(baseDir: string): string {
* stale daemon through `rig daemon start` whenever they've rebuilt
* `@openrig/daemon` but haven't rerun `build-package.sh`. That hid the
* fact that QA on local main 55e01f2e was getting a daemon WITHOUT the
* shipped slice-09 `/api/rig-mode/*` routes.
* shipped slice-09 `/api/rig-mode/*` routes (qitem-20260518054224).
*
* In an npm-install layout `packages/daemon/dist` does not exist
* (only the bundled `packages/cli/daemon` lives next to the cli);
@@ -475,7 +450,7 @@ const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
// OPENRIG_BIND_HOST (exported below when opts.host declares it). THE RULE FOR FUTURE
// PASSTHROUGH EDITS: any env var that doubles as client routing state must join this
// set — routing state silently becoming bind policy is the incident class
// (an incident where a host lost its Tailscale listener).
// (operator baton qitem-20260827070400: parent lost its Tailscale listener).
const ROUTING_ENV_SCRUB = new Set(["OPENRIG_HOST", "RIGGED_HOST"]);
const ENV_SCRUB_EXACT = new Set([
@@ -855,13 +830,6 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (unverified) {
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
}
// Expiring the readiness budget is not evidence that this owned child failed.
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
assertChild();
if (deps.isProcessAlive(pid!)) {
assertChild();
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
}
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
}
assertChild();
@@ -937,7 +905,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
const state = readState(deps);
const configured = resolveConfiguredDaemonTarget();
const explicitUrl = readOpenRigUrl();
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
@@ -1056,52 +1024,12 @@ function findSiblingHome(deps: LifecycleDeps): DaemonStatus["siblingHint"] {
return undefined;
}
/** T2 — read-only: the live launcher (else child) PID named by a start reservation, or undefined
* when there is no reservation, it is unreadable, or every PID it names is dead. */
function liveStartReservation(deps: LifecycleDeps): DaemonStatus["startPending"] {
try {
const file = path.join(deps.homeDir ?? OPENRIG_DIR, "daemon-start.lock");
const raw = deps.exists(file) ? deps.readFile(file) : null;
if (!raw) return undefined;
let launcherPid: number | undefined;
let childPid: number | undefined;
let since: string | undefined;
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const rec = JSON.parse(line) as { launcherPid?: unknown; childPid?: unknown; startedAt?: unknown };
if (typeof rec.launcherPid === "number") launcherPid = rec.launcherPid;
if (typeof rec.childPid === "number") childPid = rec.childPid;
if (typeof rec.startedAt === "string") since = rec.startedAt;
} catch {
// partial line: keep whatever parsed
}
}
for (const pid of [launcherPid, childPid]) {
if (pid === undefined) continue;
const liveness: ProcessLiveness = deps.processLiveness
? deps.processLiveness(pid)
: deps.isProcessAlive(pid) ? "alive" : "dead";
if (liveness !== "dead") return { pid, ...(since ? { since } : {}) };
}
} catch {
// best-effort only: a status read must never fail on the reservation
}
return undefined;
}
export async function getDaemonStatus(
deps: LifecycleDeps,
options: GetDaemonStatusOptions = {},
): Promise<DaemonStatus> {
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
// the normalized form; any other URL is probed as written, exactly as before.
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const normalizedOpenrigUrl = readOpenRigUrl();
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
? normalizedOpenrigUrl
: rawOpenrigUrl;
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (openrigUrl) {
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
const endpoint = (() => {
@@ -1142,13 +1070,9 @@ export async function getDaemonStatus(
// 1ae863d2: the resolved home has NO daemon state — before asserting anything,
// look for a live sibling home / HOME-MOVED marker (the wrong-home class).
const siblingHint = findSiblingHome(deps);
// T2: a refused listener under a live start reservation is a launch in progress, not down.
const refused = isRefusedError(err);
const startPending = refused ? liveStartReservation(deps) : undefined;
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err), ...(startPending ? { startPending } : {}) };
if (refused && !startPending) return { state: "stopped" };
return refused
? { state: "unverified", port: configured.port, host: configured.host, startPending, ...probeErrorCodeOf(err) }
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err) };
return isRefusedError(err)
? { state: "stopped" }
: { state: "unverified", port: configured.port, host: configured.host, ...probeErrorCodeOf(err) };
}
}
@@ -1176,12 +1100,7 @@ export async function getDaemonStatus(
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
} catch (err) {
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
// T2: unless a live start reservation says a launch is in progress.
if (isRefusedError(err)) {
const startPending = liveStartReservation(deps);
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
}
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
}
}
@@ -1399,18 +1318,6 @@ export interface KernelReadyResult {
existingRestore?: "in_progress" | "finished";
}
/** What to tell a person when daemon start skipped the kernel on purpose (kernel_state `skipped`).
* The daemon also skips when a kernel rig already exists (kernel-boot.ts); that rig need not be running.
* A longer detail with the same start is the daemon's own reason, such as a kernel already down before it started. */
export function skippedKernelMessage(detail: string | null): string {
const d = detail ?? "";
return d === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: d.startsWith("kernel rig already managed")
? `Kernel auto-boot skipped: ${d}`
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.";
}
export async function waitForKernelReady(
baseUrl: string,
timeoutMs: number,
+2 -3
View File
@@ -58,14 +58,13 @@ export function getOfflineHelpPath(): string | undefined {
}
/** Whether a command should make its real request: false only when the daemon is confirmed down
* (stopped, a stale record, or a refused health probe, meaning nothing is listening, including one under a
* pending start, whose `startPending` is set only on a refused probe), when this
* (stopped, a stale record, or a refused health probe, meaning nothing is listening), when this
* machine blocked the health probe, or when a live sibling home suggests the request would reach
* another home's daemon. A daemon that is alive but busy (its health check slow, failing or
* reporting a starved loop) gets the request, under its normal timeout. */
export function daemonMayAnswer(status: DaemonStatus): boolean {
if (status.state === "stopped" || status.state === "stale") return false;
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
if (status.probeErrorCode === "ECONNREFUSED") return false;
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
return true;
}
Loaded 100 of 849 files, more files were not shown because too many files have changed in this diff. Show more