Compare commits

..
Author SHA1 Message Date
dev-driver 1e392d56c3 fix(status): a seat waiting at a prompt only the pane shows needs attention, on every surface
A Codex seat at an approval prompt that no hook reported served agentActivity needs_input (selection_prompt, from the
cached pane reading), but its needsAttention was false: attachTerminalActivityAndWork derived the flag from a fresh
hook-store read instead of the activity the row serves, and the rig count read only the hook store. Since `rig ps`
renders the served flag (#1876), the table said "working, —" with no reason or ATTN.

The ladder's hook-or-pane step moves into hookOrPaneActivity, unchanged: a positive hook keeps its meaning, a current
usage limit stays, else the cached pane reading. The per-seat flag now reads the served activity, and the rig count
reads the same step (with the pane-reading service wired in), so the seat API, the rig and TUI count, the CLI table
and the compact JSON agree. The arbitrated signal still comes first, so an observed clear from an authoritative rung
still wins. Stale hooks still read unknown, and the pane cache drops stale readings, so nothing stale is revived. A
confirmed-missing session serves neither hook nor pane, on the row and in the count. Capture-free as before.
2026-10-24 23:36:02 +00:00
dev-driver 9c08efb59b test: a prompt only the pane reading sees counts as attention in the seat API, rig count, CLI and TUI 2026-10-24 23:36:02 +00:00
dev-driver e436cff224 fix: require exact plugin tree before fresh hook grants 2026-10-24 23:24:59 +00:00
dev-driver a52abd2b0c fix: preserve hook-file install gate with strict content renewal 2026-10-24 23:14:03 +00:00
dev-driver acea3bf39e fix: validate Codex renewal edits before writing owner config 2026-10-24 23:11:33 +00:00
dev-driver b8b95ca1df fix: preserve config formatting when renewing hook content approval 2026-10-24 22:50:13 +00:00
dev-driver ed8ca39666 fix: renew content approval for verified OpenRig Codex plugin upgrades 2026-10-24 22:47:44 +00:00
dev-driver 32633a3d8f docs: the capability map teaches 0.7.1
The reviewed change from PR 1928 (675b77ef and a13d8199, combined), applied to the 0.7.1
candidate without main ancestry: one file,
packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md.
2026-10-24 16:42:00 +00:00
dev-driver efcbd23109 fix(substance): allow the startup guide's exact preparation-root sentence
The release substance gate refused docs/reference/agent-startup-guide.md:529, an accurate
description of the default compaction preparation root, because it contains the
.openrig/compaction/ prefix. Add that one sentence to allowed_context_lines, the existing
exact-line mechanism: the complete line must match, so appended text or a concrete path
still fails. The deny tokens, path prefixes and the doc are unchanged.

Test: the shipped guide scans clean under the product rules, and the sentence with appended
text or a substituted cwd is still refused.
2026-10-24 16:38:59 +00:00
dev-driver a6320e5986 docs(changelog): add the 0.7.1 entry
The 0.7.1 release notes as an entry above 0.7.0, in the CHANGELOG's existing format. It names the tag it will be built from, as the 0.6.12 entry did.
2026-10-24 14:45:12 +00:00
dev-driver 9a83123415 chore(release): prepare 0.7.1: version, installer pins, openrig-core 0.1.18 and what's new
- Version fields 0.7.0 to 0.7.1 in the root, cli, daemon and ui package.json
  and package-lock.json, as 0.7.0's pins commit did.
- Installer pins v0.7.0 to v0.7.1 in README, getting-started and quickstart.
- openrig-core 0.1.17 to 0.1.18 in both plugin manifests. Since 0.7.0 the
  plugin's hook definitions, hook scripts, pre-compaction hook and three
  skills changed under the same version, 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.1,
  in the same commit as the version, as the package-content test requires.
2026-10-24 14:44:44 +00:00
dev-driver ef07562d02 Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"
This reverts commit 6c31f02706 on the 0.7.1
candidate line only, as 0.7.0's line did (5380ca10). Main keeps #1345.

One conflict: the native-process-lineage and managed-blocks imports in
claude-code-adapter.ts. Resolved by dropping only #1345's
witnessedClaudeVersionLabel and VersionLabelWitnessWait, and keeping #1840's
isPerSeatRoleGuidance and perSeatRoleSkipWarning.
2026-10-24 14:42:50 +00:00
861 changed files with 5061 additions and 54426 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
github: mvschwarz
+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
+144 -1593
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.4/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.1/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.1/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.4` 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.1` 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.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.1/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.1/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.
+8 -4
View File
@@ -6,11 +6,15 @@ 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.4
## Planned for 0.7.1
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
- **Team skills:** Team skills load through one router and stay out of your project.
- **Interruption levels:** Per-seat interruption levels, plus presets for managed compaction and model or effort changes.
- **Permissions:** See each existing seat's planned and current permissions.
- **Bundles:** Preview and install bundles that run services; the feature remains off by default.
- **Subscriptions (if ready):** One Fleet agent manages two registered Codex subscriptions across two Linux machines, following
your standing preferences.
- **Community:** Community contributions to launch, lifecycle, runtimes, observation and delivery.
## How to help
+5 -9
View File
@@ -24,7 +24,7 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*
# --- layer 2: pinned node (an in-range engines version — see scripts/check-engines) ---
ARG NODE_VERSION=22.23.4
ARG NODE_VERSION=22.22.1
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH}" in \
@@ -33,14 +33,10 @@ RUN set -eux; \
"") echo "TARGETARCH is empty — the legacy (non-BuildKit) builder does not populate it. Pass --build-arg TARGETARCH explicitly (build-testbed-image.sh does) or enable BuildKit. REFUSING to default to amd64, which would install wrong-arch Node." >&2; exit 1 ;; \
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
esac; \
NODE_TARBALL="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \
curl -fsSLo "/tmp/${NODE_TARBALL}" \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"; \
curl -fsSLo /tmp/SHASUMS256.txt \
"https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"; \
(cd /tmp && awk -v tarball="${NODE_TARBALL}" '$2 == tarball' SHASUMS256.txt | sha256sum -c -); \
tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1 --no-same-owner; \
rm -f "/tmp/${NODE_TARBALL}" /tmp/SHASUMS256.txt; \
curl -fsSLo /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner; \
rm -f /tmp/node.tar.xz; \
node --version; npm --version
# --- layer 3: the OpenRig package from the LOCAL pack tarball, never the npm registry ---
@@ -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`)
@@ -384,20 +356,6 @@ and stays on Herdr when neither is (`providerNameFor`,
`terminal-service.ts:175`); a named provider is used as named. These place existing tmux
sessions in views; they do not launch harnesses.
`RexAdapter` (`rex-adapter.ts`) opens a view as a new Rex session through
the `rex` CLI (`rex --autostart=false api call <method> -`, `rex-transport.ts`):
it creates an empty session (`session.create`), adds one window per grid page
(`session.new_window`), and checks the tiles once after a short wait. It is used only when named;
the CLI and TUI name it when they run inside Rex (`openRexView`,
`packages/cli/src/terminal-window-rex.ts`), and an omitted provider never picks
it. It refuses with `rex_unavailable` when the Rex server does not answer.
Discovery (`server.version` and `server.status`) has a 1 s budget, and
`status()` and `liveness()` share one probe. A request whose reply is lost (a
timeout, a killed client, an unreadable reply) is `unknown`, not failed: the
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`).
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
@@ -119,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,60 +178,6 @@ 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,
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
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
The sweep reports six finding kinds. Its custody check covers closures that
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
@@ -483,9 +429,7 @@ over a body actor. On a direct request from a known origin it is recorded as
`transport:v1`; `resolveRecordedProvenance` (`:199`) instead records
`origin-unknown:v1` when the origin-unknown marker is set or a relayed request
carried `origin-unknown:v1` (`:207`), `relay:v1` for a relayed request that
carried `transport:v1`, and `claimed:v1` for other relayed requests. `transport:v1`
says the name came from that header, which the CLI sets from the seat's
`OPENRIG_SESSION_NAME`; it isn't an authentication. Without the
carried `transport:v1`, and `claimed:v1` for other relayed requests. Without the
header, the
body actor is recorded as `claimed:v1`; with neither (or, for inbox drop,
without the header) the route answers 400 `actor_required`. The stamp lands in
@@ -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.
@@ -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
@@ -103,7 +103,7 @@ Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as cons
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
| `drop` | `state="done"`, `closure_reason="canceled"` |
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
| `resolve` | for a `blocked` item parked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`): moves it to `in-progress` with required decision/satisfaction evidence in the transition note; typed waits are recorded as blocker resolutions, not human decisions. Qitem dependencies, malformed gates and non-blocked items are refused; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
| `resolve` | only for a `blocked` item parked on a human seat: moves it to `in-progress` with the required decision text as the transition note; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
| `answer` | only for a `pending` ask addressed to the acting person: a choice (`questionId` + `optionId`) through `QueueRepository.recordHumanAnswer`, or their words (`decision`) through `resolveDirectHumanReply`, each with its existing meaning (a partial choice set stays pending; words fill the first unanswered question). Once answered, one reply row goes to the asking seat (`human-answer` tag, `replyTo` the ask, an id derived from the ask) and then the ask closes once with `human-decision-resolved`, the order Slack's answer continuation uses. With neither a choice nor words, a complete question set whose return didn't finish is sent again with exactly its recorded answers. A closed ask answers 409 `qitem_already_terminal` and records nothing; a refused answer is 400 `answer_not_recorded`; 409 `answer_not_closed` means the ask stayed open: its return failed, it changed before its close, or a retry came in other words than the reply already returned (that retry isn't sent). The TUI's Feed sends it |
`annotate` requires `annotation`, `hold` and `drop` require `reason`, `route`
@@ -114,8 +114,8 @@ the audit record instead; the wrapped `reason_required` error has no status
mapping, so it returns 500 (`mission-control-action-log.ts:128–133`,
`mission-control-write-contract.ts:245–248`). Items already `done` or
`handed-off` are refused with 409 `qitem_already_terminal`, except under
`resolve`, which answers 409 `qitem_not_leg1_parked` for any item that is not
blocked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`). An unknown item returns 404 `qitem_not_found`
`resolve`, which answers 409 `qitem_not_leg1_parked` for any item not parked on
a human seat. An unknown item returns 404 `qitem_not_found`
(`routes/mission-control.ts:240–251`, `:330–341`).
Each verb except `answer` is one atomic daemon transaction (`answer`'s queue writes are the repository's own, in the order above, then its audit row and event): the queue mutation via
@@ -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
-4
View File
@@ -112,7 +112,3 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
- 2026-10-19, #1483, managed compaction: a helper-only command-composition check did not bind the submitted command to the precompact effect. `managed-compaction-effect.test.ts` now drives the real enforcer command into a version-bound stub input model and checks the shipped precompact marker; its 851-character pasted-command control lacks that effect despite a successful submit receipt. `rig compact` rejects stub runtimes, so this is an adapter/helper check, not an installed CLI or native compaction proof. The model uses the recorded Claude Code 2.1.282 distinction; other versions and actual context compaction remain unverified here.
- 2026-10-19, #484, refocus orientation: script tests supplied basis strings without composing parked typed queue state through `queue whoami` and the hook. `refocus-parked-baton.yaml` plus the active-baton contrast now assert the shipped hook's own emitted context; installed-scenario runs healthy/seeded/healthy; its scratch trace-child fault moves the parked row from held work into the work candidates as current work, and the hook's emitted context must then fail with that row's slice shown as `WORK FROM QUEUE EVIDENCE`. This proves the composed diagnostic effect, not native hook scheduling or an agent reading and acting on it. With the work packet's bounds and per-runtime default, the parked scenario runs with the packet on and asserts the row as held work. `refocus-parked-baton-packet-off.yaml` keeps the labelled fallback as the packet-off control.
- 2026-10-23, F1 (0.7.0 candidate `2fec7382`), launch (a fresh Codex kernel): a fresh Codex kernel stopped at Codex's hook review. Caught at the cut, before publication, by native first-user and daily runs. Missed by the stub layer, which has no real Codex binary. Fixed in #1824.
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
+19 -59
View File
@@ -9,23 +9,24 @@ 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: 92a2b8f7157661716a9c79de742cd82a96d1e6f7
last-updated: 2026-10-09
---
# CLI Reference — Registered Commands and Options
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
Verified against source commit `92a2b8f7157661716a9c79de742cd82a96d1e6f7`.
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 **87 top-level registrations**, **356 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,084 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 terminal rows below also include the later named-session options.
## Reading this reference
@@ -143,7 +144,7 @@ Root: `rig`; declared option: `-V, --version`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig daemon` | — | — |
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--allow-db-conflict`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
| `rig daemon stop` | — | — |
| `rig daemon status` | — | — |
| `rig daemon logs` | — | `--follow` |
@@ -188,8 +189,10 @@ 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 show <entityId>` | — | `--json` |
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human quiet end <entityId>` | — | `--actor <actor>`<br>`--json` |
@@ -205,26 +208,8 @@ the sections that were not read are `null`.
| `rig gateway human mode room <entityId>` | — | `--json` |
| `rig gateway human mode decide <qitemId>` | — | `--application <id>` **required**<br>`--choose <question=option...>`<br>`--reason <why>` **required**<br>`--reversible`<br>`--irreversible`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode decisions <entityId> <applicationId>` | — | `--json` |
| `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 +339,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 +361,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` |
@@ -435,7 +403,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--type-without-checking`<br>`--reason <text>`<br>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--reason <text>`<br>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
### stream
@@ -451,7 +419,6 @@ do not establish connectivity to a live peer.
| `rig stream unsubscribe <readerSession>` | — | `--json` |
| `rig stream subscriptions` | — | `--json` |
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
### queue
@@ -469,7 +436,7 @@ do not establish connectivity to a live peer.
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `rig queue transitions <qitemId>` | — | `--json` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--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
@@ -790,12 +756,12 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig provider` | — | — |
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>`<br>`--claude-token-file <path>`<br>`--credential-label <label>`<br>`--all-hosts`<br>`--hosts <ids>` |
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>`<br>`--all-hosts`<br>`--hosts <ids>` |
| `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>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<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` |
@@ -981,15 +946,10 @@ for input and receipt examples, rollback, and snapshot behavior.
| `rig seat handover <seat>` | — | `--source <source>`<br>`--reason <reason>`<br>`--operator <address>`<br>`--dry-run`<br>`--json` |
| `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-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat effort <seat>` | — | `--json` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--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>`--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` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>` **required**<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat continue <seat>` | — | `--json` |
| `rig seat stop <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat clean <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
+28 -52
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,8 +143,8 @@ 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 library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| 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/`: 17 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` |
| In-container entry and result check | `packages/test-system/ci/run.mjs`, `packages/test-system/ci/result.mjs` |
@@ -190,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:
@@ -205,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
@@ -278,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. |
@@ -299,17 +279,13 @@ The most useful next pieces of harness work, roughly in dependency order:
### Library status
Seven library scenarios run in CI: `queue-baton-survives-restart` (since #243) and
`refocus-parked-baton` with their seeded pairs, and `transcript-reads-addressed-seat`,
`capture-returns-addressed-seat`, `daemon-restart-preserves-team-identity`,
`refocus-active-baton` and `refocus-parked-baton-packet-off` in passing form only. The
`fixture` case runs the `scenario-02-baton` test fixture through the same seeded pair.
Three more, `down-stops-every-seat`, `send-renders-in-addressed-pane` and
`up-relaunch-restores-a-two-seat-team`, run with the local runner but not in CI, and
`compaction-restore-resumes-role` runs the compaction round-trip's success path the same
way. Those ten later scenarios are not in this table; "Status today" under
[Help wanted](#help-wanted-command-families-without-a-behavioural-scenario) covers seven
of them. The other nine scenarios are authored but not yet runnable as meaningful checks:
Four scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
and `transcript-reads-addressed-seat`, `capture-returns-addressed-seat` and
`daemon-restart-preserves-team-identity` in passing form only. Three more,
`down-stops-every-seat`, `send-renders-in-addressed-pane` and
`up-relaunch-restores-a-two-seat-team`, run with the local runner but not in CI. Those six later scenarios are not in this table; see "Status
today" under [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario). The
other ten scenarios are authored but not yet runnable as meaningful checks:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
@@ -318,10 +294,11 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
| `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 |
| `compaction-restore-resumes-role` | Step-time `emit` is unbound | Input-triggered hooks plus evidence specific to one seat |
| `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
@@ -464,12 +441,8 @@ that loses the team's row, or changes its seat counts or status, fails on the po
`rig down` then records it stopped, so the restart left a governable team. It does not prove
that queue items survive the restart (that is `queue-baton-survives-restart.yaml`), that a
kill inside a handoff window is reconciled (that is `kill-daemon-mid-handoff.yaml`), the
port-and-process half of the row, or that the panes are gone after `down`.
`compaction-restore-resumes-role` covers the compaction round-trip's success path: the
seat's launch script (delivered through `env.stub_scripts`) fires the real precompact and
restore seams in order, and impl's own pane carries the injected directive. It runs with
the local runner, not in CI (container mode refuses `env.stub_scripts` loud and named), and
its seeded wrong-seat-restore negative case is not armed yet.
port-and-process half of the row, or that the panes are gone after `down`. Everything else
has none.
Before you start, two honest constraints:
@@ -592,6 +565,9 @@ How to make a case useful:
Read these with care. This page notes them and proposes no changes.
- `packages/test-system/ci/README.md` describes the CI scenario job, except its
"Run one case" section, which still says both cases and six runs; the script runs four cases
and eight containers by default.
- The headers of `transcript-reads-addressed-seat.yaml` and `capture-returns-addressed-seat.yaml`
say "host mode only". Both run in the CI container job, which executes the host-mode pipeline
inside the image.
+1 -9
View File
@@ -342,21 +342,13 @@ six destinations (Topology, Project, For You, Library, Search & Audit,
Settings); and `DashboardFooter`. Its glyphs come from
`dashboard/vellum/fidelity-glyphs.js`.
The kernel card renders `kernel_state: "skipped"` as a neutral **boot not
tracked** state. Built-in boot can be skipped because a kernel rig is already
managed or because automatic boot was disabled; the served reason remains
visible. Ready-agent counts do not promote this state to healthy. An unavailable
tracker or a malformed status envelope remains **unknown**. Recovery still uses
the existing launch-plan and daemon restore guards.
The vellum barrel `dashboard/vellum/index.ts` still exports
`BackLayerContent` and `BackVellumSheet`; `/lab/vellum-lab`
(`lab/VellumLab.tsx`) imports from it.
> Source: `components/dashboard/Dashboard.tsx` (header comment `:1-24`,
> glyph import, `DESTINATIONS`, data wiring, render order);
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`;
> `components/KernelStatusCard.tsx`; `components/RigStatusCard.tsx`.
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`.
## 5. Cross-cutting properties
+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 -43
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 |
+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
-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.
@@ -4,10 +4,10 @@ name: cursor
command: openrig-cursor-launch
maintainer: Your name or team
limits:
- startup assisted; check the pane and submit any staged identity text
- resume reads a chat as missing unless its store is in the seat's Cursor config folder, under the seat's working directory
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
launch_result: true
resume: true
startup_assisted: false
first_input: true
startup_assisted: true
@@ -34,13 +34,6 @@ chat_store() {
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# 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 '.')
first_input=${first_input%.}
set -- "$@" "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
plain_id "$id" || { echo "openrig-cursor-launch: refusing an unusual chat id" >&2; exit 2; }
@@ -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,7 +5,7 @@ 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
@@ -1,15 +0,0 @@
# The Gateway must already run. Configure its route and authentication in this seat's OPENCLAW_CONFIG_PATH.
format: 1
name: openclaw-gateway
command: openrig-openclaw-launch --gateway
maintainer: Your name or team
limits:
- connects to an existing Gateway; this launcher never starts or stops that service
- stopping the TUI closes its client; an accepted turn may keep running on the Gateway
- uses only agent main; automatic session resets must stay off
- typing /new changes the native key while the seat keeps the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -4,13 +4,10 @@ name: openclaw
command: openrig-openclaw-launch
maintainer: Your name or team
limits:
- runs locally in the pane (openclaw tui --local), using only agent main, with no Gateway; Gateway-attached use isn't covered
- 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 resume may run a native database integrity check; check that the TUI has finished startup before sending work
- resume not declared until reopening a named session, and a missing one, have been checked with a real OpenClaw
- runs locally in the pane (openclaw chat), with no Gateway; Gateway-attached use isn't covered
- automatic session resets (session.reset) must stay off, or a key can start a new conversation
- typing /new in the pane moves the chat to a fresh session key; the seat still holds the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -1,70 +1,38 @@
#!/bin/sh
# Reference launcher for OpenClaw 2026.10.6 (fe06181), not installed by OpenRig.
# Each seat owns its client state and uses the main agent. OpenRig's opaque name maps to its full native key.
# --gateway selects an existing Gateway configured for this client; the default is an embedded local agent.
# Resume checks that exact key at its owner; an unavailable lookup is not a missing conversation.
# A reference launcher for OpenClaw's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md (`names_conversation`):
# - a new launch (OPENRIG_CONVERSATION set): OpenClaw's local chat starts on the session key OpenRig named;
# - a resume (OPENRIG_RESUME_REF set): it first checks that this seat's sessions list the key, and stops if they
# don't, so a gone conversation isn't quietly recreated under its old key.
# It runs `openclaw chat` (the TUI with --local): the agent runs in the seat's own pane, with no Gateway. Arguments after
# the launcher's name go to every launch and resume alike.
set -eu
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
mode=local
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
# Each seat gets its own OpenClaw state folder: --local needs it to itself, and seats don't share sessions.
# Its provider settings and credentials come from that folder's config or the environment, not the person's ~/.openclaw.
OPENCLAW_STATE_DIR="${OPENRIG_HOME:-$HOME/.openrig}/state/openclaw/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export OPENCLAW_STATE_DIR
mkdir -p "$OPENCLAW_STATE_DIR"
name="${OPENRIG_RESUME_REF:-${OPENRIG_CONVERSATION:-}}"
if [ -z "$name" ]; then
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
fi
case "$name" in
agent:main:*) key="$name" ;;
agent:*) echo "openrig-openclaw-launch: this example supports only the main OpenClaw agent" >&2; exit 2 ;;
*) key="agent:main:$name" ;;
esac
# 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 '.')
first_input=${first_input%.}
set -- "$@" --message "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
if [ "$mode" = gateway ]; then
params=$(KEY="$key" node -e 'process.stdout.write(JSON.stringify({ key: process.env.KEY, agentId: "main" }))')
if listing=$("$openclaw" gateway call sessions.describe --params "$params" --json); then listed=true; else listed=false; fi
else
if listing=$("$openclaw" sessions --agent main --json --limit all); then listed=true; else listed=false; fi
fi
if [ "$listed" = false ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
exit 4
fi
if printf '%s' "$listing" | KEY="$key" MODE="$mode" node -e '
key="$OPENRIG_RESUME_REF"
# A key may be stored bare or as agent:<id>:<key>; either counts as this conversation.
if ! "$openclaw" sessions --json --limit all | KEY="$key" node -e '
let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => {
try {
const data = JSON.parse(raw);
if (process.env.MODE === "gateway") {
if (data.session === null) process.exit(3);
process.exit(data.session?.key === process.env.KEY ? 0 : 4);
}
const { sessions } = data;
if (!Array.isArray(sessions)) process.exit(4);
process.exit(sessions.some((s) => s?.agentId === "main" && s.key === process.env.KEY) ? 0 : 3);
} catch { process.exit(4); }
const key = process.env.KEY;
const sessions = JSON.parse(raw).sessions || [];
process.exit(sessions.some((s) => s.key === key || String(s.key).endsWith(`:${key}`)) ? 0 : 1);
});'; then
:
else
result=$?
if [ "$result" -eq 3 ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\"; not starting a new one under that key" >&2
else
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
fi
exit "$result"
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\" (or couldn't be listed); not starting a new one under that key" >&2
exit 3
fi
exec "$openclaw" chat --session "$key" "$@"
fi
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
exec "$openclaw" tui --local --session "$key" "$@"
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
exec "$openclaw" chat --session "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -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
+20 -192
View File
@@ -4,33 +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.
## 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. |
| 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. |
| 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.
**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
@@ -47,7 +20,6 @@ launch_result: true # optional: the launcher reports the conversatio
# names_conversation: true # optional, instead of launch_result: OpenRig names the conversation (below)
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)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
@@ -60,15 +32,11 @@ first_input: true # optional: launcher takes the first turn from
### Assisted startup
Set `startup_assisted: true` when startup submission needs a person's check. The seat may start its first turn
automatically, including from input passed at launch with `first_input: true`. This declaration requires a check after
the submission attempt; it does not hold the agent back. Without `first_input`, OpenRig pastes the startup text and
presses Enter once.
After attempting startup input, OpenRig reports **attention required**: check the pane and submit any text still in the
composer. OpenRig does not resend the identity prompt. Once the seat responds, use the reported
`rig seat clear-attention <session> --reason "Startup submission checked; seat responded"` command to acknowledge the
check. A launch with no startup text needs no such check. Other presets keep their existing behavior.
Set `startup_assisted: true` when the harness doesn't reliably submit OpenRig's startup text. OpenRig still pastes the
text and presses Enter once. If it sends startup text, the running seat then shows **attention required**, rather than
ready: check its pane and submit any text still in the composer. Don't resend the identity prompt. After the seat responds,
use the reported `rig seat clear-attention <session> --reason "Startup submission checked; seat responded"` command to
acknowledge the check. A launch with no startup text needs no such check. Other presets keep their existing behavior.
## Reporting the conversation a launch created (`launch_result: true`)
@@ -155,9 +123,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
@@ -168,8 +134,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.
@@ -177,21 +142,17 @@ Copy them and maintain them as your own.
[`community-harness-examples/openclaw/`](community-harness-examples/openclaw/) has a preset and a small launcher for
OpenClaw's CLI.
- **Choose the mode:** `openclaw.yaml` runs an embedded agent with `tui --local`. `openclaw-gateway.yaml` runs a TUI client of an existing Gateway. Configure that client's Gateway route and authentication through `OPENCLAW_CONFIG_PATH`; the launcher never starts or stops the Gateway.
- **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 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.
- **The launcher:** it runs `openclaw chat` (the TUI with `--local`) on the session key OpenRig named, with its own
OpenClaw state folder per seat, so the agent runs in the seat's pane with no Gateway.
- **Its resume check:** before reopening a key, the launcher checks that the seat's `openclaw sessions --json` lists it,
and stops if not, so a gone conversation isn't recreated under its old key. That check is the launcher's code, not
OpenRig's.
- **Resets must stay off:** OpenClaw can start a new conversation under the same key on a reset rule
(`session.reset`). That's off by default; with it on, a key no longer names one conversation. Likewise, typing `/new`
in the pane moves the chat to a fresh session key, while the seat still holds the name OpenRig gave it.
- **The preset:** it declares `names_conversation` but not `resume`, until both have been checked with a real OpenClaw.
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.
Copy both and maintain them as your own.
**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.
@@ -206,146 +167,13 @@ CLI.
- **The preset:** it declares `launch_result` and `resume`. Cursor's `--resume` with an id it doesn't hold starts a new,
empty chat without saying so, so on a resume the launcher first checks that the chat's store exists. When it doesn't, the
launcher reports `error: "missing"` and starts nothing.
- **First input at launch:** the preset declares `first_input: true` and `startup_assisted: false`. The launcher passes
the generated startup text as Cursor's positional first prompt. This route and same-chat resume were checked with
Cursor Agent 2026.10.22-27dcf5f. Later tool approvals use Cursor's native prompts; startup submission does not answer them.
- **Startup is assisted:** identity text has remained in Cursor's composer after the initial Enter. The preset declares
`startup_assisted: true`; check and submit it in the pane as above. Unattended startup submission is not confirmed.
- **Sender names come from the sending session.** A command run from an unattached operator shell can arrive as
`<unknown sender>`; that doesn't mean the receiving Cursor seat lost its identity.
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.
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` or `first_input` is refused by an older
module into its daemon. A preset that sets `launch_result`, `names_conversation` or `startup_assisted` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
The contributor-owned launcher reads the file and uses its harness’s supported initial-prompt route: the Cursor example passes one positional prompt; OpenClaw uses `--message`. Core knows neither flag. If no initial identity turn is due (an ordinary resume, for example), the variable is absent and the launcher starts without a first message. Later startup files and actions use their existing delivery paths; they are not folded into the launch file.
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.
## 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
-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`.
+13 -99
View File
@@ -39,12 +39,6 @@ The `rigs` skill is the procedure: follow it in order. This page is what it look
## Before you start
Setup and launch are separate: declining `rig setup` avoids its `~/.tmux.conf` change, but starting Codex seats can
still write workspace trust and selected MCP/config fragments. `runtime.codex.hooks_enabled=false` controls hooks;
it isn't a no-global-config option. Setup has no project-only mode. Trusted project `.codex/config.toml` settings
don't redirect OpenRig's home-config writes. A separate selected `CODEX_HOME` keeps Codex config, login and sessions
together in that home; it needs its own working sign-in and doesn't relocate `~/.tmux.conf`.
> **What OpenRig changes on your machine**
> - **Install:** `@openrig/cli` goes in globally with npm.
> - **Setup:** `rig setup` adds an OpenRig block to `~/.tmux.conf`. It can install tmux (with Homebrew on macOS), and
@@ -52,8 +46,7 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
> Claude Code and Codex and may install a missing one; `rig setup --providers claude` (or `codex`) limits it to
> yours.
> - **Starting OpenRig, for the coding agent you use** (paths are the usual defaults):
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Codex: hooks and trust in `~/.codex/config.toml`.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
@@ -191,11 +184,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.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.1/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.1/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
@@ -262,9 +255,6 @@ applies to future launches and successors, not running agents or your personal t
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
On Linux, a seat also gets the daemon's `XDG_RUNTIME_DIR`, so tools whose sockets live there work in seats,
with `DBUS_SESSION_BUS_ADDRESS=disabled:` so its D-Bus clients don't find your session bus on their own. A seat's
own `env:` value for either name wins. See "The runtime directory, on Linux" in the rig spec reference.
## Start the kernel and check its state
@@ -331,16 +321,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
@@ -404,14 +384,13 @@ caller's Herdr endpoint must match the daemon's endpoint; a mismatch is reported
without opening a window or putting the view in another session.
The shared TUI (`rig tui --shared`) runs in the daemon's tmux server, so its own environment never
carries the Herdr or Rex you are looking at; the markers are on the tmux client attached to it. When you
carries the Herdr you are looking at; the markers are on the tmux client attached to it. When you
open a terminal view from it on Linux, the TUI passes the markers of the client that was active last
(normally the one that pressed the key) to the command, which then opens the view in that Herdr, or as
a new session on that Rex server. The
(normally the one that pressed the key) to the command, which then opens the view in that Herdr. The
chosen client may belong to a different viewer: tmux only counts keys a client types, so a key sent
by an agent (`send-keys`) or by a control-mode client doesn't count as any client's activity, and the
view opens in the Herdr or Rex of the interactive client that typed last, however long ago. With no client
attached, more than one client active in the same second, or a client that is in neither or can't be
view opens in the Herdr of the interactive client that typed last, however long ago. With no client
attached, more than one client active in the same second, or a client that isn't in Herdr or can't be
read, nothing is passed and the result is what it was before. Other platforms, and every other way of
running `rig terminal open`, keep the previous result too.
@@ -704,15 +683,9 @@ Herdr, use `rig terminal open starter --provider herdr`; for the shared
dashboard together with the kernel conversations, use the
[saved first view](#open-the-kernel-conversations). The equivalent
cmux provider is also available. Read the opened/absent/degraded result: a
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.
partial terminal view is not a healthy team. Repeated terminal-open calls can
create another provider workspace; return to the one already open when you
want to preserve it. 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.
@@ -762,56 +735,6 @@ with `rig grow` and no YAML, or optionally author a custom rig. It covers
new-seat context/work ownership, concurrency costs and saving the expanded spec.
This guide remains the short first-use path.
## Move a seat to the other runtime
When a provider reaches its usage limit, or a seat would do better on the other
runtime, move that one seat instead of rebuilding the team. Ask your operator
agent, or run it yourself:
```bash
rig seat set-runtime dev-review@starter --runtime claude-code --reason "Codex limit" --dry-run
rig seat set-runtime dev-review@starter --runtime claude-code --reason "Codex limit"
```
The dry run shows the target's settings and where each comes from (an explicit
option, the seat's agent spec for the target runtime, or the runtime's own
default), what is cleared, and the costs below. Nothing changes until you run it
without `--dry-run`.
The new runtime takes over before the old one stops. Its successor starts beside
the current occupant, reads a handover (the seat's recap, its open rows and the
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
--confirm <switch-id> --note "<what it will do first>"`. Only then does the seat
move: same address, connections and queue rows, with in-progress claims moved
to the successor. If the successor doesn't confirm within 180 seconds
(`--confirm-timeout`), nothing changes and the current occupant keeps the seat.
A seat can switch itself. `rig seat status <seat>` shows an open or interrupted
switch, and `rig seat set-runtime <seat> --recover` finishes one from its record.
What it costs:
- **A new conversation.** Native history isn't converted; only the handover
carries over.
- **The same address, a different terminal.** The seat's pane and scrollback
change.
- **The team file isn't rewritten.** The database and `rig export` show the new
runtime; the YAML you imported keeps the old one, so a later converge against
it reports a deferred `change_runtime`. Export the rig, or edit the member's
`runtime:`, to keep them in step.
- **Source settings are cleared, not mapped.** The model, effort, Codex profile
and an explicit native permission choice don't carry over, and neither does a
model or effort the agent spec declares for the source runtime. Member and rig
permission policy are kept.
- **Two processes until cutover.** The successor runs on the target's account
while the switch is open.
- **Messages are held during the switch.** A `rig send` to the seat while it
switches isn't typed; it says the seat is switching and until when. Send it
again afterwards to reach whoever holds the seat.
- **Leftover files after a failed switch.** The target runtime's prepared seat
storage and projections can remain.
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
run by a switch; the dry run lists them.
## Stop your teams
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
@@ -851,16 +774,14 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| 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 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
@@ -1137,10 +1058,6 @@ absolute working directory, then uses that exact path for discovery and launch.
It does not use interactive shell aliases or a shell's modified PATH. Relative
PATH entries and a relative `CLAUDE_CONFIG_DIR` resolve from the seat directory.
In `plan` mode Claude Code answers with Sonnet when the seat's model is the alias
`haiku` (a full Haiku model id, set with `rig seat set-model`, keeps Haiku), and the
launch result and `rig seat status` say so for that pairing.
For these explicit native modes, fresh, resume, fork and legacy restore use the
same managed environment: PATH, HOME, `CLAUDE_CONFIG_DIR` (default HOME/.claude)
and the configured classic-renderer setting. Other shell customizations are
@@ -1265,9 +1182,6 @@ using the selected database. To stop before opening that database instead, run
`rig config set daemon.db_conflict stop` (set it to `warn` for the default).
The check requires an open-file holder, a matching `daemon.json`, and a listener
reporting the same PID; missing or inconclusive evidence does not stop startup.
When the check can't run (`lsof` is missing, fails or times out, or a recorded daemon's
listener doesn't answer), startup warns that ownership is unknown rather than reporting
no conflict, in either setting.
Selecting a database outside `OPENRIG_HOME` alone only warns; a confirmed live-daemon
conflict there still stops when `daemon.db_conflict` is `stop`.
-13
View File
@@ -390,19 +390,6 @@ ID reads retain them while the source can still project them. A recurrence after
clear receives a new episode ID. This is an on-demand projection, not a history
store; a detail 404 does not prove resolution.
In the TUI, the instance Table and the rig/pod Table and Overview load canonical
findings for their health summaries. These summaries previously could show `Not assessed`
even when the daemon served findings because they did not request the projection.
Once the topology drill is resolved, these pages use the existing bounded
`GET /api/health?limit=200` read; pod summaries continue to cover their rig.
Other topology views using focused reads keep this projection lazy; the Health
tab requests it explicitly.
This findings read is separate from the control-plane `GET /healthz` read.
If `GET /api/health?limit=200` fails, including HTTP 503, the summary shows
`Unknown · Unavailable`. A projection that was not requested remains
`Unknown · Not assessed`, and a loaded empty result is not a healthy verdict.
Lists default to 100 and cap at 200, with `total` and `truncated` explicit.
Ceremony findings take precedence before the cap so context pressure cannot
hide the primary signal. Narrow a truncated query; do not certify the unseen
+3 -12
View File
@@ -120,12 +120,6 @@ unknown outcome, check `rig seat status <seat>` before trying again. For a rig w
`--non-interruptive` on `rig up` or `rig bundle install` avoids Claude's warning and Codex's notices in the first
place; see `non-interruptive-mode.md` beside this file.
Codex's initial sign-in chooser and API-key entry are recognised as `login_required`, so startup reports the
sign-in step instead of waiting for a generic readiness timeout. Finish that step in the seat's terminal, then use
the continuation command above. A quoted or dismissed login screen is not a current login gate; other layouts
keep their existing readiness handling. Claude's persistent "Not logged in" footer alone is not a login refusal, because a
configured gateway may authenticate its requests.
### You can't tell which instance or configuration is involved
Read [instance layout](instance-layout.md) (`rig context get reference/instance-layout.md`) and
@@ -139,9 +133,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 +141,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.
-21
View File
@@ -165,27 +165,6 @@ says so. Use it rather than the single-machine `rig provider status --json`: one
machines appears once, with each machine's own observation beside it, so you can't count it twice. If it answers that
usage windows are off, tell the person that before setting the watch, as you would an unreachable channel.
For a Claude login supplied by `CLAUDE_CODE_OAUTH_TOKEN`, an explicit one-credential read is available:
```sh
rig provider status --refresh --provider claude --claude-token-file /absolute/private/personal.token --credential-label Personal
```
The file must be readable by the daemon, regular, at most 8 KiB, and contain only the token. Ownership by the daemon user and mode 0600 or
0400 are recommended; different ownership, broader permissions and symlinks produce warnings, not refusals. Provision it through your existing credential
custody; never put token values in arguments, seat environment settings, evidence or a queue item. The path is on the selected daemon's
machine. Repeat the command for another labelled file when you want to compare subscriptions. This option does not fan out with
`--all-hosts` or `--hosts`.
**Each token refresh spends one model turn.** Status and TUI reads show the cached result and never probe. A timeout or refusal does not
prove no allowance was spent. The probe uses a temporary home/config/work directory and a single Haiku turn, with CLI flags requesting no tools, hooks or MCP. Unsupported CLI flags or missing events are reported as failures/unknown, not as free capacity. The event format is evidenced on
Claude Code 2.1.310; other versions may report different fields. These are credential labels you supplied, not provider-verified account
names, and do not prove a running seat uses that credential. The ordinary status-line sample/account join remains unproven.
Only actually reported window percentages and resets are measurements. A rejected window can have an unknown percentage; overage rejection
is separate from subscription rejection. Readings and their times survive daemon restart, marked historical until refreshed. Files with
different references/labels stay separate; equal resets or matching labels on another host never establish one allowance or extra room.
**Never put `--refresh` in the timer's check.** `rig provider status --all-hosts --refresh` starts a native read of the
accounts on every machine. Run it only when the person asks for a fresh reading now.
-18
View File
@@ -108,24 +108,6 @@ ownership receipt. A later seat start with no project-world input preserves it;
an explicit install whose `install.skills` is empty clears it. This keeps
"project not supplied" distinct from "project deliberately selects no skills."
Before refreshing a skill copy, don't take `rig skill audit` as the answer. The
audit reports declared provenance, date freshness (its `stale` is a verified date
past 90 days or one with no evidence source) and copy hashes. It doesn't say
whether a copy is safe to refresh, and two copies with different hashes aren't
evidence of a deliberate fork. For a managed copy in a working folder, the
read-only check is `rig skill loadout --runtime <claude-code|codex> --cwd
<target> --project-root <project> --json`, without `--apply`. It reports
`current`; `stale`, still OpenRig's last projection, so `--apply` can refresh it;
`conflicting`; `shadowed`; or `missing`. Read the detail of `conflicting` and
`shadowed`: `conflicting` can mean the bytes differ or that the target couldn't
be inspected, which leaves any difference unknown, and a confirmed difference
doesn't prove a deliberate local edit. A seat's own skills, kept outside the
project (0.7.1 and later), are listed with where they're stored by
`rig skill loadout --seat <session|self> --json`; that read doesn't classify
them. A global or untracked copy has no install
record, so its origin stays unknown: ask the skill's owner, and don't move global
skills into a project just to get one.
## Work-install project selection
`rig context work-install` picks one project from the catalog at
-16
View File
@@ -28,17 +28,6 @@ Two rules matter most for strangers:
manifest and consumers show a notice; anyone with the bundle could read those files.
Unrecorded login files and other sensitive paths still fail. See [harness login files](rig-bundle.md#harness-login-files).
To start from a running team, use `rig export <team> -o ./my-team/rig.yaml` after creating `my-team`.
Export keeps the rig's name, including an old `starter` name; choose a descriptive `name` in `rig.yaml`
before sharing. `rig bundle create --name` names the archive, not the installed rig.
For a local daemon, export copies the declared culture file from its recorded rig root and keeps any
existing destination file. Check that file before sharing. A missing, inaccessible or remote source is
reported for manual copying. If the daemon is reached through a localhost tunnel, copy the culture from
that host and check it yourself. Agent refs outside the folder are reported as nonportable: copy those agents and their
imports into the folder and use `local:` refs, or let `rig bundle create --rig-root <original-root>` vendor
agents into an archive. To include an existing README, declare `docs: [{ path: README.md }]` in `rig.yaml`;
export does not invent a README or its contents. Check the folder after these edits.
Two optional files sit beside `rig.yaml`:
- **`bundle.yaml`** declares what people need before installing: setup steps (`preconditions`, shown before install
and never run), the oldest OpenRig that works (`compatibility.min_cli_version`), and skills shared by several
@@ -120,11 +109,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.4`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.1`. 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
+9 -189
View File
@@ -190,23 +190,6 @@ edges:
**Launching one spec under another name.** `rig up <spec> --name <rig>` creates the rig as `<rig>` instead of the spec's `name:`, so one spec can run as several rigs at once, for example `--name alpha` and `--name beta`, with seats `{pod}-{member}@alpha` and `{pod}-{member}@beta`. The spec file is not changed. The rig keeps the chosen name from then on: `rig ps`, `rig down alpha`, `rig up alpha --existing`, snapshots and restore all use it. The same checks as a launch without `--name` apply to the chosen name: a running rig of that name refuses the launch, and a stopped one is archived and replaced, as an explicit spec import does today. `--name` does not rename an existing rig, so the CLI refuses it with the name of an existing rig to restore, with a topology and with a GitHub bundle link. A library spec name works: `rig up starter --name beta` launches the library spec as `beta` even while a rig named `starter` exists. Over `--host`, pass the spec path or bundle file with `--name`. A spec that sets `services.project_name` refuses `--name`: every rig launched from it would share that Compose project, and `rig down` or a failed service boot of one would stop the others' services. Without `project_name`, each rig gets its own project. Two launches of the same spec started at the same moment can answer 409 (already in progress for this source) even under different names; start them one after the other.
### Starting one materialized seat
`rig import ./team.yaml --materialize-only` records the team without starting its harnesses.
For a seat that has never started, use its positional logical ID:
```bash
rig launch <rig-id> dev.lead --json
```
This starts only that seat, preserving the rig's node IDs and the other seats. The daemon retains
the materialized declaration and resolves its agent and startup files from the original source
root at launch time; keep those files available. Current saved model, effort and permission
choices take precedence over the retained declaration; launch reports the differences. A prior snapshot isn't required. Seats with
session history continue through restore. Explicit `--snapshot-id` and `--seats` use restore too.
`--plan` previews restore selection; it requires a snapshot and does not preflight this first start.
Rigs materialized by older daemons lack the retained declaration; this path doesn't retrofit them.
### Attaching a permission policy
Attach a policy to a rig with `permission_policy`, either at the rig level or on a member:
@@ -236,131 +219,6 @@ future managed launches of that stable seat; it does not rewrite this spec or
its inherited policy provenance. `inherit` removes that override. See
[per-seat permission mode](getting-started.md#per-seat-permission-mode).
### Per-member Claude tool rules
Claude members can record tool rules independently, even when they share a project folder:
```yaml
claude_permissions:
allow: ["Bash(node lane-reader.mjs *)"]
deny: ["Read(./private/**)"]
```
This field accepts only `allow` and `deny` lists of native Claude rule strings. It is
also supported on legacy flat Claude nodes. Other runtimes, `ask`, mode settings,
arbitrary settings and parenthesized MCP rules are rejected. Shape validation does
not establish support by every Claude version.
The initial spec records the rules on the stable seat. For an existing seat, put the
complete replacement in a JSON file and use the existing audited permission command:
```sh
rig seat set-permissions dev-reader@team --rules member-rules.json --reason "Select the reader lane"
rig seat status dev-reader@team --json
```
Choose either `--rules` or `--mode`, never both. Empty allow and deny lists clear the
seat's rules. Updating rules leaves the permission mode, running process and history
unchanged; they apply at the next separately authorized managed launch or resume.
Restore and same-seat handover read that same record. A fork copies the source's
currently recorded rules independently, including retained-seat updates. Export
contains the current record. An invalid or unreadable record refuses that seat's
launch, fork or export with a repair message rather than silently omitting its denies.
OpenRig composes these rules into its one operational `--settings` value, preserving
team/kernel defaults, hooks and managed-compaction settings. It never writes them
into the shared project's Claude settings. Without the field or a recorded update,
launch arguments are unchanged. Status reports the next-launch plan and lists the
added rules; it does not verify native enforcement.
Rules add to inherited native permissions; an allow list cannot withdraw broader
user or project grants. In bypass mode, allows do not constrain access. Managed
policy and installed mods can change rule behavior; see [Claude's permission
precedence](https://code.claude.com/docs/en/permissions#precedence-among-rules-hooks-and-mods).
These are native tool rules, not OS-user or credential isolation. Native acceptance
and effects must be checked on the Claude version and configuration being used.
**Downgrade:** a file-backed SQLite and launch-composition check against the
pre-feature reader at `75c1f045` found that it can open the upgraded database but
omits the recorded rules from launch arguments. This pair is not
restriction-preserving. The stored bytes survive and the supporting reader recovers
them; a process already launched by older code is unchanged. Keep a copy of the
intended rules and establish equivalent native restrictions before using an older
managed-launch path. Other version pairs and native enforcement need their own checks.
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
@@ -498,33 +356,16 @@ pods:
- A seat gets the rig's `env` with its own `env` laid over it, name by name.
- The same environment is applied on first launch, on `rig up <rig-name>` restore, and to the successor of a seat handover.
- `rig up <spec> --env KEY=VALUE` (repeatable) adds or overrides a value for one launch, except `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, which the CLI leaves out with a note (see "A seat's own account" below). A one-off value belongs to the terminal that launch creates: it stays in that terminal's environment for as long as the terminal lives, and OpenRig removes it from the tmux session's environment as soon as the terminal has started, so a terminal started later in that session does not get it. If OpenRig can't take it out, the seat keeps running and the launch warns, naming the variable and the tmux command to put it back or remove it. Until then, a terminal started later in that session would get it. A restore, a handover successor or any later launch starts from the spec's `env:` alone, so the value is not saved and does not carry forward. To keep a value, put it in `env:`. One exception to "does not carry forward", for a managed Claude seat (see below): a one-off name that the seat's spec does not declare stays on the session's list of names, `OPENRIG_SEAT_ENV_KEYS` (names only, no values), because the first launch and an in-place restart of the same terminal read that list. So if a later pane in that session gets a value for that name from the tmux server's own environment, its managed Claude launch can forward that value, where a launch without `--env` would leave it out. The first launch's one-off value itself is not passed on to that pane. This comes from reading the code and has not been measured. `--env` does not apply to `rig up <rig-name>` (a restore) or to a topology (several rigs from their own specs); the CLI refuses both. For a topology, put `env:` in each rig's spec.
- **The runtime directory, on Linux.** When the daemon runs with an absolute `XDG_RUNTIME_DIR` (usually `/run/user/<uid>`), a seat gets the same `XDG_RUNTIME_DIR`, so a tool that keeps its socket there, such as an MCP server's daemon or `gpg-agent`, works inside the seat without per-tool setup. It comes with `DBUS_SESSION_BUS_ADDRESS=disabled:`, an address that D-Bus clients refuse. sd-bus, GDBus and libdbus look for your session bus at `$XDG_RUNTIME_DIR/bus`, or start one, only when that variable is unset, so a seat doesn't find your session bus (the keyring, notifications) on its own. This is a default, not isolation: `systemctl --user` reaches your service manager through its own socket in the runtime directory, as any tool there can.
- A seat's own value wins. Set `DBUS_SESSION_BUS_ADDRESS` in `env:` to give a seat a bus. Set `XDG_RUNTIME_DIR` there to choose the directory yourself; the seat then gets no bus address, as before, so its D-Bus clients find the bus in that directory. A name listed in tmux's `update-environment` counts as the seat's own too.
- A shell startup file that runs in the seat's terminal can still change either name. One that sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`, where before its clients found your bus; set the bus address in `env:` or in that file to keep it.
- With `terminal.inherit_tmux_environment` on, a seat gets tmux's environment as before, and OpenRig adds neither name. On macOS, and for a daemon started without `XDG_RUNTIME_DIR`, nothing changes.
- Codex passes only a fixed list of variables to the MCP servers it starts, so a Codex seat's MCP server gets the runtime directory only when its entry in `config.toml` names it: `env_vars = ["XDG_RUNTIME_DIR"]`. Codex's hooks and shell commands get it without that, and so do Claude Code's MCP servers and hooks. Pi and OMP agent processes don't get it (see below).
- It applies at a seat's next launch, restore or handover; a seat that is up keeps the environment it started with.
- `rig up <spec> --env KEY=VALUE` (repeatable) adds or overrides a value for one launch. A one-off value belongs to the terminal that launch creates: it stays in that terminal's environment for as long as the terminal lives, and OpenRig removes it from the tmux session's environment as soon as the terminal has started, so a terminal started later in that session does not get it. If OpenRig can't take it out, the seat keeps running and the launch warns, naming the variable and the tmux command to put it back or remove it. Until then, a terminal started later in that session would get it. A restore, a handover successor or any later launch starts from the spec's `env:` alone, so the value is not saved and does not carry forward. To keep a value, put it in `env:`. One exception to "does not carry forward", for a managed Claude seat (see below): a one-off name that the seat's spec does not declare stays on the session's list of names, `OPENRIG_SEAT_ENV_KEYS` (names only, no values), because the first launch and an in-place restart of the same terminal read that list. So if a later pane in that session gets a value for that name from the tmux server's own environment, its managed Claude launch can forward that value, where a launch without `--env` would leave it out. The first launch's one-off value itself is not passed on to that pane. This comes from reading the code and has not been measured. `--env` does not apply to `rig up <rig-name>` (a restore) or to a topology (several rigs from their own specs); the CLI refuses both. For a topology, put `env:` in each rig's spec.
- Names starting `OPENRIG_` are reserved for OpenRig and refused. Empty values are refused, because the launch omits empty variables.
- `rig export` writes the rig and member `env:` back out.
**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.
- 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.
**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
holds. The member's value wins over the rig's. OpenRig follows it everywhere it launches or reads that runtime: the
launch, Claude's first-run trust settings, Codex's managed `config.toml`, hooks and plugin, conversation capture for
resume, context usage, `rig seat status` and `rig provider status`. A value that isn't an absolute path isn't used:
the seat resolves as if it declared none, and `rig seat status` shows the ignored value. Name the home in `env:`: a
one-off `rig up --env` value for either variable isn't applied, because a restore, a handover and `rig seat status` read
only the stored `env:`. Seats may share a home. Live usage refresh (`rig provider status --refresh`) still reads only the
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.
- 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. 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. `XDG_RUNTIME_DIR` itself is not forwarded, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` and would reach the keyring and `systemctl --user`. `DBUS_SESSION_BUS_ADDRESS`, `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded either. 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, and use the daemon's `CODEX_HOME` 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`, `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.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
@@ -596,7 +437,6 @@ 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). |
| `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. |
@@ -653,9 +493,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.
- **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.
- **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.
### Oh My Pi (`runtime: omp`)
@@ -718,11 +556,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
@@ -805,20 +639,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.
`rig export` includes the rig's saved culture file and startup block, and now also
writes each pod's saved startup files under that pod. The file order, delivery
hints, required flags, orientation and `applies_on` selections are retained.
Previously the saved pod file lists were omitted. Pods without a recorded file
list, including older rigs, keep their existing export behavior; the exporter
does not reconstruct declarations from a member's combined startup context.
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 or pod file;
missing optional files do not trigger that warning. Pod startup actions and
member startup blocks remain outside this export; keep the source spec when you
need those declarations.
### Files
| Field | Type | Required | Default | Description |
@@ -827,7 +647,7 @@ need those declarations.
| `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
@@ -845,8 +665,8 @@ need those declarations.
| `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 -9
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
@@ -180,9 +174,7 @@ outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
include them.
The three sections project into the UI's one review structure: a vertical
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
+5 -31
View File
@@ -1,44 +1,21 @@
# Per-seat terminal delivery
The typing guard has six modes. `off` and `hold` are the two ends. Draft-aware adds an
opt-in check for unfinished input without a second control or daemon-owned retry
scheduler. Between off and hold, three interruption levels choose which automatic messages may
type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their own mode.
The existing typing guard supports three modes. Before draft-aware mode,
automatic input either used the ordinary delivery path or was held unconditionally.
Draft-aware adds an opt-in check for unfinished input without a second control or
daemon-owned retry scheduler. Sibling seats keep their own mode.
| Mode | Terminal input | When input is unavailable |
|---|---|---|
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
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.
## Configure and inspect
### Who may intervene
@@ -123,7 +100,7 @@ rig seat held-messages dev-impl@my-rig --id <outbox-id> --json
rig seat retire-held-message dev-impl@my-rig <outbox-id> --reason "read outside the pane"
~~~
Use exactly one of `--mode off|draft-aware|openrig-urgent|urgent-only|sends-only|hold` or the compatible
Use exactly one of `--mode off|draft-aware|hold` or the compatible
`--enabled true|false`: true selects hold, false selects off. There are no
retry settings or deadlines. Activation waits for an already-started seat operation
to finish. The API may return HTTP 202 with `pending: true`; wait for
@@ -253,9 +230,6 @@ current parked-row eligibility and stop checks still apply. This changes message
structured continuity actions such as creating a cutover baton.
The parked-owner supervisor also retries current obligations despite an earlier failed, unknown or unrecorded attempt.
Its first reminder to a seat waits until the seat's waiting state has been unchanged for 5 minutes, then goes on the next
watchdog tick, so a seat that just handed work off or asked a person isn't interrupted; a seat still holding the work after
that is reminded as before. A seat at an approval or question prompt gets no grace, so the prompt-blocked alert isn't delayed.
A recorded success to the same known occupant waits for a change in its obligations. Rotation includes retained attempts,
so a held input cannot starve another owner. Interactive prompts stay with the existing prompt-refusal recovery.
Its own reminders wait while managed compaction owes the turn boundary, restore or audit, including a fresh check
-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.
+3 -21
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
@@ -191,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
@@ -207,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" }`
@@ -276,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)
@@ -323,7 +305,7 @@ decisions, and posts its own replies.
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
prove they have drained: it does not count relay HTTP requests still in flight.
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
- **Who answered:** every post ends `from <seat> on <machine>`, using the machine's host id (set
it with `host.name`).
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
machine only, and never the hub's own default channel.
+4 -222
View File
@@ -30,58 +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
to 30 seconds for the same session name and managed seat identity to return,
then reattach with the original read-only setting. This uses the shared tile
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
handover and restart keep the existing attachment.
A deliberate detach closes the tile after about two seconds. A different seat
under the old name is never followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
opened before this change must be closed and reopened to get this behavior.
## Desktop windows and previews
```sh
@@ -116,43 +74,6 @@ when Herdr is absent, and combining a session with cmux is rejected. Existing
workspaces are kept during reuse checks. Any suggested cleanup command for a
stale named-session workspace includes the selected socket.
## Rex
```sh
rig terminal open future-rig --provider rex --json
rig terminal open future-rig --window --json
rig terminal status --provider rex --json
```
`--provider rex` opens the view as a new session on the daemon's Rex server,
one window per page of the view, without switching any app to it. A tile whose
attach fails within ten seconds stays open showing the error, and the result
lists that seat as degraded when the failure shows up in the check made about
two seconds after opening. OpenRig picks
Rex only when it is named, or for `--window` (or no provider) when the command
runs inside a Rex block, including in a tmux started there. Everywhere else the
defaults above are unchanged, and the daemon's open and preview APIs never pick
Rex for a request that names no provider.
From inside Rex, the window form first checks that the daemon uses the same Rex
server as the calling terminal. When they differ, nothing is opened. After the
open it asks the Rex app showing the caller's session to switch to the new one,
choosing the app whose window holds the caller's terminal when several show it.
When no app can be chosen, or the app refuses the switch, the result names the
session to select by hand. A Rex app accepts the switch only with Remote Control
turned on in its Settings, under Rex Server.
The daemon finds `rex` in `$REX_BIN_DIR`, then on `PATH`, then inside the Rex
app on macOS, and uses its own `REX_SERVER` when set. It never starts a Rex
server. An open view is not reused: opening it again adds a numbered session,
such as `future-rig (2)`. `--session` applies to Herdr only. An unreachable Rex
server returns `rex_unavailable` and a failed session create returns
`rex_session_failed`, both as HTTP 200 results with `ok: false`. When Rex
doesn't confirm a session create or a page, for example because its reply
timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
adds a note when other pages opened, and names the session to check in Rex
before retrying. `rig terminal status` gives Rex about a second to answer.
## HTTP API
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
@@ -167,142 +88,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: {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.
+2 -3
View File
@@ -98,9 +98,8 @@ after an upgrade it can still hold the previous version's note.
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)).
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed
instead of typing it.
- **`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
+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).
+86 -366
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -47,8 +47,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.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",
@@ -63,8 +62,6 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+25 -78
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();
@@ -15,9 +14,6 @@ export function terminalAuthHeaders(): Record<string, string> {
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
/** The caller's occupant generation (its seat env), read only by a runtime switch's own checks. */
export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
/**
* P18 sender-provenance — derive the caller's identity from the seat ENV (never a request-body
* claim). Routes that record authorship into the channel of record read ONLY this header, so a
@@ -35,13 +31,7 @@ export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: s
originSelfHostId && originSelfHostId.length > 0 && session.split("@").length < 3
? `${session}@${originSelfHostId}`
: session;
// Slice 16: the caller's own occupant generation from its seat env, so the two processes of one seat during a
// runtime switch can be told apart. Only a client acting for its own process carries it.
const generation = sessionName === undefined ? process.env.OPENRIG_OCCUPANT_GENERATION?.trim() : undefined;
return {
[SENDER_IDENTITY_HEADER]: value,
...(generation && /^[0-9A-Za-z-]{1,64}$/.test(generation) ? { [OCCUPANT_GENERATION_HEADER]: generation } : {}),
};
return { [SENDER_IDENTITY_HEADER]: value };
}
/** Construct registered remote clients with the origin derived from this instance's durable store. */
@@ -147,15 +137,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 +161,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,77 +169,43 @@ export class DaemonClient {
private identity?: Promise<Record<string, string>>;
private originUnknown = false;
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
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 };
}
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
* the origin unknown. */
async senderIdentity(): Promise<{ session: string | undefined; originUnknown: boolean }> {
this.identity ??= this.identityHeaders();
const headers = await this.identity;
return { session: headers[SENDER_IDENTITY_HEADER], originUnknown: headers["X-OpenRig-Origin-Unknown"] === "true" };
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);
}
constructor(baseUrl?: string, options?: DaemonClientOptions) {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigUrl();
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -273,11 +222,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. */
@@ -362,7 +309,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 }) => {
+9 -10
View File
@@ -13,11 +13,6 @@ function shellQuote(value: string): string {
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
// 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+) (.+)$/;
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
@@ -27,11 +22,15 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
}
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;
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
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.
+1 -2
View File
@@ -105,7 +105,7 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
});
withHome(withRuntime(auth.command("switch <profile>")))
.description("Restore saved credential bytes (sign-in is unverified).")
.description("Activate a saved profile.")
.addHelpText("after", `
With --host, the switch runs on that machine, over its SSH entry, and copies a profile already saved there; name the
home with --codex-home (rig auth status --host <id> shows the home it would use). Only the command and its output
@@ -121,7 +121,6 @@ cross the connection.`)
console.log(`activated_profile: ${r.name}`);
console.log(`active_auth: ${r.activePath}`);
console.log(`active_mode: ${r.mode}`);
console.log("note: Restored 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.");
console.log(`note: ${r.note}`);
});
+5 -78
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.
@@ -115,17 +89,6 @@ function renderCapture(data: Record<string, unknown>): void {
if (box && typeof box.hint === "string" && box.status !== "empty" && box.status !== "no-input-box") {
console.error(`[rig capture: ${r["sessionName"]}: input box: ${box.hint}]`);
}
// 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 +127,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 +134,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
@@ -186,17 +146,6 @@ Supported notes:
A suggestion isn't the person's intent, a draft or a staged command. Ambiguous evidence says
unknown. --json has it as inputBox: status, confidence, the cursor and the input and
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.
- --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 +178,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 +208,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 +275,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 +314,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 -29
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;
@@ -70,7 +53,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom send <rig> "message" [--sender <name>]
cmd
.command("send")
.description("Post a message to a rig's chat room; the sender is this seat")
.argument("<rig>", "Rig name")
.argument("<message>", "Message to send")
.option("--sender <name>", "(deprecated, ignored) the sender is derived from the seat env (X-OpenRig-Session); P21 made the chat route derive it from the transport header")
@@ -109,7 +91,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom history <rig> [--topic <name>] [--limit N] [--json]
cmd
.command("history")
.description("Print a rig's chat messages and topic markers, with filters")
.argument("<rig>", "Rig name")
.option("--topic <name>", "Filter by topic")
.option("--after <id>", "Messages after this message ID")
@@ -173,7 +154,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom watch <rig> [--tmux]
cmd
.command("watch")
.description("Print a rig's chat messages as they arrive; --tmux runs it in its own tmux session")
.argument("<rig>", "Rig name")
.option("--tmux", "Run watch in a dedicated tmux session")
.action(async (rig: string, opts: { tmux?: boolean }) => {
@@ -192,11 +172,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;
@@ -257,7 +235,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom topic <rig> <topic-name> [--body "text"]
cmd
.command("topic")
.description("Start a new topic in a rig's chat room")
.argument("<rig>", "Rig name")
.argument("<topic-name>", "Topic name")
.option("--body <text>", "Optional body text")
@@ -293,7 +270,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom wait <rig>
cmd
.command("wait")
.description("Wait for the next matching message in a rig's chat room; exits 1 when --timeout passes")
.argument("<rig>", "Rig name")
.option("--after <id>", "Only messages after this ID")
.option("--topic <name>", "Filter by topic")
@@ -406,7 +382,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
// chatroom clear <rig>
cmd
.command("clear")
.description("Delete every message in a rig's chat room")
.argument("<rig>", "Rig name")
.action(async (rig: string) => {
const client = await getClient();
+24 -25
View File
@@ -113,8 +113,13 @@ interface PreviewWire {
missingFiles: Array<{ path: string; role: string }>;
}
/** A write the OS refused for this process: a sandboxed seat's writable roots don't include the topology tree. */
const WRITE_REFUSED_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
const SAFE_REF_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function assertSafeTopologySegment(kind: "rig" | "seat", value: string): void {
if (value === "." || value === ".." || !SAFE_REF_SEGMENT.test(value)) {
throw new Error(`unsafe ${kind} segment '${value}' — topology addresses require one bounded path segment`);
}
}
function assertLocalGitClient(client: DaemonClient): void {
if (!["127.0.0.1", "localhost", "[::1]"].includes(new URL(client.baseUrl).hostname)) {
@@ -676,7 +681,6 @@ Examples:
// library verb — nothing here sends to a seat. Naming rig/seat (or mission)
// is the caller's explicit grant of read access to that directory subtree.
cmd.command("profile")
.description("Compose a context pack's atoms into the profile for one --situation and print it")
.argument("<name-or-ref>", "Context pack name or path-like ref (its manifest must declare atoms)")
.requiredOption("--situation <situation>", "fresh | handover | post-compaction")
// r1 4d obs 2: default from the seat's own environment — a codex seat that
@@ -778,8 +782,7 @@ Examples:
// trace: the seat dir resolves from topology.root CONFIG; the write flows
// through the ONE store (supersession + addressability gate); advisory
// contract findings ride stderr while the write still lands — the boundary
// is never blocked on prose shape. A process that may not write the topology
// tree (a sandboxed seat) hands the same recap to the daemon, which owns it.
// is never blocked on prose shape.
cmd.command("recap-write")
.description("Write this seat's authored RECAP (decisions-with-rationale) at the handover boundary; supersedes the previous recap into the seat's chain")
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
@@ -787,33 +790,29 @@ Examples:
.requiredOption("--file <path>", "Markdown file containing the recap content")
.action(async (opts: { rig: string; seat: string; file: string }) => {
try {
const { writeSeatRecapAt, validateRecapContract } = await import("@openrig/daemon/seat-recap-store");
const { writeSeatRecap, validateRecapContract, listRecapChain } = await import("@openrig/daemon/seat-recap-store");
const content = readFileSync(opts.file, "utf-8");
const topologyRoot = String(new ConfigStore().resolveWithSource("topology.root").value);
const store = new ConfigStore();
const topologyRoot = String(store.resolveWithSource("topology.root").value);
assertSafeTopologySegment("rig", opts.rig);
assertSafeTopologySegment("seat", opts.seat);
const rigDir = join(topologyRoot, "rigs", opts.rig);
if (!existsSync(rigDir)) {
throw new Error(`rig directory ${rigDir} does not exist — check --rig against the topology tree (topology.root=${topologyRoot}).`);
}
const seatDir = join(topologyRoot, "rigs", opts.rig, "seats", opts.seat);
assertDestinationNamespaceContained(join(topologyRoot, "rigs"), `${opts.rig}/seats/${opts.seat}/RECAP.md`);
mkdirSync(seatDir, { recursive: true });
for (const f of validateRecapContract(content)) {
console.error(f.kind === "no-decisions-section"
? "ADVISORY no-decisions-section: the authoring contract asks for decisions WITH rationale — conclusions alone are the lossy handoff shape."
: `ADVISORY nonstandard-unverified-marker (line ${f.line}): use the canonical 'UNVERIFIED:' form so uncertain facts stay findable.`);
}
let written: { path: string; superseded: number; daemonUrl?: string };
try {
written = writeSeatRecapAt({ topologyRoot, rig: opts.rig, seat: opts.seat, content });
} catch (err) {
if (!WRITE_REFUSED_CODES.has((err as NodeJS.ErrnoException).code ?? "")) throw err;
// The daemon owns the topology tree (it primes RECAP.md at the next rebuild): it writes the same recap through
// the same store, so a sandboxed seat saves its recap without a wider writable root. Said first, so a daemon
// that can't be reached is reported with its reason.
console.error(`Note: this process can't write the topology tree (${(err as Error).message}), so the daemon writes the recap instead.`);
const client = await getClient();
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: opts.rig, seat: opts.seat, content });
if (res.status !== 200 || typeof res.data.path !== "string") throw new Error(res.data.message ?? `Daemon returned HTTP ${res.status}`);
written = { path: res.data.path, superseded: res.data.superseded ?? 0, daemonUrl: client.baseUrl };
}
console.log(`Recap written: ${written.path}${written.superseded > 0 ? ` (${written.superseded} superseded predecessor${written.superseded === 1 ? "" : "s"} retained)` : ""}`
+ (written.daemonUrl ? `, by the daemon at ${written.daemonUrl}` : ""));
writeSeatRecap({ seatDir, content });
const chain = listRecapChain(seatDir);
console.log(`Recap written: ${join(seatDir, "RECAP.md")}${chain.length > 0 ? ` (${chain.length} superseded predecessor${chain.length === 1 ? "" : "s"} retained)` : ""}`);
} catch (err) {
// A daemon write that timed out says its outcome is unknown, so it's checked before a retry supersedes it again.
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
+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}` };
},
+1 -5
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(
+3 -20
View File
@@ -13,7 +13,7 @@ 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";
@@ -31,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;
@@ -129,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") {
@@ -395,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();
@@ -444,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 }),
+2 -9
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 {
@@ -109,10 +108,8 @@ async function showAgentsBeforeDown(rigId: string, readInventory: () => Promise<
const inventory = await readInventory();
if (Array.isArray(inventory) && inventory.every(n => n && typeof n.nodeKind === "string"
&& typeof n.logicalId === "string" && (n.sessionStatus === null || typeof n.sessionStatus === "string"))) {
const live = (inventory as DownAgentEntry[]).filter(n => ["running", "idle", "unknown"].includes(n.sessionStatus ?? ""));
const agents = live.filter(n => n.nodeKind === "agent");
// A terminal member isn't an agent, but stopping the rig ends its session too.
const terminals = live.filter(n => n.nodeKind !== "agent").map(n => n.canonicalSessionName || n.logicalId);
const agents = (inventory as DownAgentEntry[]).filter(n => n.nodeKind === "agent"
&& ["running", "idle", "unknown"].includes(n.sessionStatus ?? ""));
facts = agents.length === 0 ? "no agent sessions reported live" : "recorded agent sessions: " + agents.map(n => {
// A running process or an assigned queue row does not prove work in progress.
// Prefer the daemon's arbitrated activity; older daemons expose agentActivity.
@@ -124,7 +121,6 @@ async function showAgentsBeforeDown(rigId: string, readInventory: () => Promise<
const work = ["working", "idle", "needs-input"].includes(activity ?? "") ? activity : "unknown";
return `${n.canonicalSessionName || n.logicalId} (activity: ${work})`;
}).join(", ");
if (terminals.length > 0) facts += `; terminal sessions: ${terminals.join(", ")}`;
}
} catch { /* Unavailable inventory is an unknown fact, never a teardown gate. */ }
console.error(`Before stopping ${rigId}: ${facts}.`);
@@ -299,9 +295,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 }) => {
-3
View File
@@ -38,7 +38,6 @@ Examples:
// rig env status <rig>
cmd
.command("status")
.description("Show the state of a rig's services")
.argument("<rig>", "Rig name or ID")
.option("--json", "JSON output")
.action(async (rig: string, opts: { json?: boolean }) => {
@@ -95,7 +94,6 @@ Examples:
// rig env logs <rig> [service]
cmd
.command("logs")
.description("Print recent logs from a rig's services, or from one service")
.argument("<rig>", "Rig name or ID")
.argument("[service]", "Specific service name")
.option("--tail <n>", "Number of lines", "100")
@@ -128,7 +126,6 @@ Examples:
// rig env down <rig> [--volumes]
cmd
.command("down")
.description("Stop a rig's services as its down_policy says; --volumes also removes volumes")
.argument("<rig>", "Rig name or ID")
.option("--volumes", "Also remove volumes")
.action(async (rig: string, opts: { volumes?: boolean }) => {
+5 -60
View File
@@ -13,30 +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");
const sourceRoot = fs.realpathSync(root), source = fs.realpathSync(nodePath.resolve(root, file));
const inside = (base: string, target: string) => target.startsWith(base.endsWith(nodePath.sep) ? base : base + nodePath.sep);
if (!inside(sourceRoot, source) || !fs.statSync(source).isFile()) throw new Error("culture source is not a file inside the rig folder");
const target = nodePath.resolve(outDir, file);
if (fs.lstatSync(target, { throwIfNoEntry: false })) {
console.warn(`Warning: kept existing culture '${target}'; check it matches the team before sharing.`);
return;
}
let parent = nodePath.dirname(target);
while (!fs.existsSync(parent)) parent = nodePath.dirname(parent);
const outputRoot = fs.realpathSync(outDir), realParent = fs.realpathSync(parent);
if (realParent !== outputRoot && !inside(outputRoot, realParent)) throw new Error("culture destination resolves outside the export folder");
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
console.log(`Copied culture to ${target}`);
}
export function exportCommand(depsOverride?: ExportDeps): Command {
const cmd = new Command("export").description("Export a rig spec as YAML");
const getDeps = (): ExportDeps => depsOverride ?? {
@@ -88,32 +64,6 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const { parse } = await import("yaml");
const parsed = parse(res.data) as Record<string, unknown> | null;
if (parsed && typeof parsed === "object") {
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 }>;
}> | 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;
if (typeof member.agent_ref === "string" && (member.agent_ref.startsWith("path:") || nodePath.isAbsolute(member.agent_ref.replace(/^local:/, ""))
|| (local !== null && (local === ".." || local.startsWith(".." + nodePath.sep))))) {
console.warn(`Warning: ${pod.id}.${member.id} has an agent_ref outside this folder and is not portable. Copy that agent and its imports into this folder and use local: refs before sharing, or use rig bundle create with the original --rig-root to vendor them into an archive.`);
}
}
}
const culture = parsed["culture_file"] ?? parsed["cultureFile"];
if (typeof culture === "string" && recordedRoot) {
try {
if (!["localhost", "127.0.0.1", "[::1]"].includes(new URL(client.baseUrl).hostname)) throw new Error("the daemon is remote; copy it from the recorded rig root on that host");
copyCulture(culture, recordedRoot, outDir);
} catch (error) {
console.warn(`Warning: could not copy culture '${culture}' from '${recordedRoot}': ${(error as Error).message}`);
}
}
const filesToCheck: string[] = [];
if (typeof parsed["cultureFile"] === "string") {
filesToCheck.push(parsed["cultureFile"]);
@@ -121,16 +71,11 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
filesToCheck.push(parsed["culture_file"]);
}
const startupBlocks = [
parsed["startup"] as ExportedStartupBlock | undefined,
...(Array.isArray(pods) ? pods.map((pod) => pod.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;
}
+1 -1
View File
@@ -470,7 +470,7 @@ list/explain never mutate. Diagnosis mutations use explicit subcommands; automat
.option("--json", "Summary JSON with omitted fields, original byte size and exact full command")
.option("--full", "Complete original record; use --full --json for lossless JSON (may be large)")
.action(async (id: string, o: { json?: boolean; full?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}`, o));
diagnosis.command("record <qitem-id>").description("Record an agent's disposition for one occurrence from a JSON --file").requiredOption("--file <path>", "Disposition JSON with verdict, causalStart, steering, uncertainty, evidenceRefs").option("--json")
diagnosis.command("record <qitem-id>").requiredOption("--file <path>", "Disposition JSON with verdict, causalStart, steering, uncertainty, evidenceRefs").option("--json")
.action(async (id: string, o: { file: string; json?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}/disposition`, o, { value: fromFile(o.file) }));
diagnosis.command("notify <qitem-id>").description("Explicitly request human delivery under policy and verified connector readiness").option("--json")
.action(async (id: string, o: { json?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}/notify`, o, {}));
+16 -86
View File
@@ -23,7 +23,6 @@ import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executo
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.
@@ -72,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 {
@@ -155,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,
};
}
@@ -206,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` });
@@ -300,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.
@@ -329,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({
@@ -536,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)");
@@ -586,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
@@ -856,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
+1 -1
View File
@@ -85,7 +85,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
.option("--snapshot-id <id>", "Use this exact restore-usable snapshot")
.option("--retry-startup-from <member-file>", "Retry a stopped first start that failed during projection, using its original member fragment")
.option("--rig-root <path>", "Original absolute source root for --retry-startup-from")
.option("--plan", "Show restore selection and non-target effects without mutation; does not preview first start of a never-started materialized seat")
.option("--plan", "Show subset selection and non-target effects without mutation")
.option("--json", "JSON output")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (rigHandle: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
+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);
+1 -6
View File
@@ -55,13 +55,8 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
};
const experimental = cmd.command("experimental").description("Optional Jev experiment; advisory, finite foreground runs, disabled by default");
const experimentVerbDescriptions = {
enable: "Turn the experiment on in its --config file, with any new limits, and print its status",
disable: "Turn the experiment off in its --config file and print its status; later runs make no request",
status: "Print the experiment's settings from its --config file; changes nothing",
} as const;
for (const verb of ["enable", "disable", "status"] as const) {
experimental.command(verb).description(experimentVerbDescriptions[verb]).requiredOption("--config <file>", "Explicit local experiment JSON; contains no credential")
experimental.command(verb).requiredOption("--config <file>", "Explicit local experiment JSON; contains no credential")
.option("--max-requests <n>", "Maximum requests per foreground run, 1..20")
.option("--timeout-ms <n>", "Per-request deadline, 1000..30000")
.option("--json", "JSON output")
+3 -33
View File
@@ -3,39 +3,9 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { Command } from "commander";
import YAML from "yaml";
import { inferCatalogProject, ProjectReadError } from "@openrig/daemon/project-catalog";
import { DaemonClient } from "../client.js";
import { ConfigStore } from "../config-store.js";
import { findSlice, readProjectMissionsRoot, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
import { ScopeCliError, type SliceInfo } from "../lib/scope/types.js";
/** findSlice, then, for an absolute slice path the configured tree doesn't own, the catalogued project whose root holds it
* (chosen as work-install chooses one for a working folder), read under that project's missions root. A tree named with
* --workspace or OPENRIG_WORK_ROOT stands, and a retry that fails reports the original refusal. */
function findSliceInCatalog(
missionsRoot: string, slicePath: string, hintMission: string | null, workspace: string | undefined, warnings: string[],
): SliceInfo {
try {
return findSlice(missionsRoot, slicePath, hintMission);
} catch (error) {
if (!(error instanceof ScopeCliError) || !path.isAbsolute(slicePath) || workspace || process.env.OPENRIG_WORK_ROOT) throw error;
let projectMissionsRoot: string | null = null;
try {
const catalogPath = String(new ConfigStore().resolveWithSource("workspace.catalog_path").value);
const project = inferCatalogProject(catalogPath, new ProjectReadError("project_required", "no catalogued project holds the slice"),
{ cwd: slicePath }, warnings);
projectMissionsRoot = readProjectMissionsRoot(project.root, warnings);
} catch {
throw error;
}
if (!projectMissionsRoot || path.resolve(projectMissionsRoot) === path.resolve(missionsRoot)) throw error;
try {
return findSlice(projectMissionsRoot, slicePath, hintMission);
} catch {
throw error;
}
}
}
import { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
import { ScopeCliError } from "../lib/scope/types.js";
import { selectProofContractBody, isScaffoldPlaceholderText } from "../lib/scope/scaffold-placeholder.js";
import { parseLogicalCheckboxes } from "../lib/scope/logical-checkbox.js";
@@ -260,7 +230,7 @@ checkboxes do not accept an item under the selected proof policy.
}
const parentOpts = (command.parent?.opts() ?? {}) as { workspace?: string };
const missionsRoot = resolveMissionsRoot({ override: parentOpts.workspace });
const slice = findSliceInCatalog(missionsRoot, slicePath, opts.mission ?? null, parentOpts.workspace, warns);
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
// Resolve the artifact body.
let body = "";
+24 -162
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError } 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";
@@ -20,44 +20,9 @@ export const USAGE_ACROSS_HOSTS_READ_TIMEOUT_MS = 20_000;
// deadline: 50 s + 15 s + a 10 s margin = 75 s.
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 = { 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;
function printSeat(seat: SeatView): void {
if (seat.home) console.log(`home: ${seat.home}`);
if (seat.activity) console.log(`seat activity: ${seat.activity}`);
for (const peer of seat.peers) {
const where = peer.home === "same" ? "peer on this home" : peer.home === "different" ? "seat on another home" : "seat, home unknown";
console.log(`${where}: ${peer.sessionName ?? peer.logicalId}: ${peer.effect}`);
if (peer.step) console.log(` then: ${peer.step}`);
}
}
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 } | 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 } : {}) };
}
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("; ")}`);
}
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",
"Provider accounts, usage signals, and interruption-safe account switching",
);
const getDeps = (): StatusDeps =>
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -174,16 +139,12 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
const c = (r["configured"] ?? {}) as Record<string, unknown>;
const configured = c["source"] === "registry"
? `configured: profile ${c["profileRef"]} (registry, recorded ${c["recordedAt"]}${c["profileSaved"] === false ? "; profile not saved on this host" : c["profileSaved"] === null ? "; whether the profile is saved: unknown" : ""})`
: c["source"] === "default_login" && c["homeFrom"]
? `configured: the seat's own home (${c["home"]}, from its ${c["homeFrom"]} env)`
: c["source"] === "default_login"
? `configured: default login${c["home"] ? ` (${c["home"]})` : ""}`
: `configured: unknown (${c["reason"]})`;
const allowance = (r["allowance"] ?? {}) as Record<string, unknown>;
console.log(` ${r["seatSession"]} ${r["runtime"]} ${configured} · allowance not linked`);
if (c["source"] === "default_login" && c["homeNote"]) console.log(` ${c["homeNote"]}`);
const ignored = r["ignoredHome"] as Record<string, unknown> | undefined;
if (ignored) console.log(` ignored: its ${ignored["from"]} env sets ${ignored["variable"]}=${ignored["value"]}, which isn't an absolute path, so it isn't used as this seat's home`);
for (const note of (r["registryNotes"] as string[] | undefined) ?? []) console.log(` ${note}`);
const observed = r["observed"] as Record<string, unknown> | undefined;
const differs = (observed?.["differsFrom"] as string[] | undefined) ?? [];
@@ -204,7 +165,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
function identityLabel(id: Record<string, unknown>): string {
// Codex: account, else the workspace account/read reported; Claude: the organisation and email auth status
// reported at the last refresh; else unresolved. For Codex the email is a label.
return id["source"] === "configured credential label" ? `credential ${id["credentialLabel"]} (operator label)` : id["accountId"] ? `account ${id["accountId"]}`
return id["accountId"] ? `account ${id["accountId"]}`
: id["workspaceId"] ? `workspace ${id["workspaceId"]} (from account/read; no usage account id)`
: id["source"] !== "unresolved" && id["orgId"] ? `organisation ${id["orgName"] ? `${id["orgName"]} (${id["orgId"]})` : id["orgId"]}`
: "identity unresolved";
@@ -256,7 +217,6 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
: id["source"] === "unresolved" ? " [from an earlier read; possibly this same login]"
: " [from an earlier read; the latest read didn't match this row]";
console.log(`${pad}${r["provider"]} ${labels.join(" · ")}${earlier}`);
if (r["probe"]) console.log(`${pad} token probe: ${JSON.stringify(r["probe"])}`);
if (r["identityNote"]) console.log(`${pad} ${r["identityNote"]}`);
const unresolved = r["unresolved"] as Record<string, unknown> | undefined;
if (unresolved) {
@@ -268,7 +228,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
const level = w["levelReason"] ? `${w["level"]} (${w["levelReason"]})` : String(w["level"]);
const fresh = w["freshnessReason"] ? `${w["freshness"]}: ${w["freshnessReason"]}` : String(w["freshness"]);
const seat = w["seatSession"] ? ` by seat ${w["seatSession"]}` : "";
console.log(`${pad} ${windowName(w)} ${left} ${level} resets ${w["resetsAt"] ?? "unknown"} observed ${w["observedAt"]}${seat} (${fresh})${w["status"] ? ` · provider ${w["status"]}` : ""}`);
console.log(`${pad} ${windowName(w)} ${left} ${level} resets ${w["resetsAt"] ?? "unknown"} observed ${w["observedAt"]}${seat} (${fresh})`);
for (const d of (w["disagreeingSeats"] as Array<Record<string, unknown>> | undefined) ?? []) {
console.log(`${pad} seat ${d["seatSession"]} reports ${Math.max(0, 100 - Number(d["usedPercent"]))}% left resets ${d["resetsAt"] ?? "unknown"} observed ${d["observedAt"]}`);
}
@@ -281,7 +241,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
const last = r["lastRefresh"] as Record<string, unknown> | null;
if (last?.["outcome"] === "failed") {
// Claude's refresh reads identity only; its windows keep coming from the seats' samples.
console.log(r["provider"] === "claude" && (r["source"] as Record<string, unknown> | undefined)?.["kind"] !== "claude_token_probe"
console.log(r["provider"] === "claude"
? `${pad} identity refresh failed at ${last["at"]}: ${failureText(last)}; the identity shown is from the last refresh that worked, if any`
: `${pad} refresh failed at ${last["at"]}: ${failureText(last)}; last reading kept, marked old`);
}
@@ -409,22 +369,14 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
.option("--json", "Output as parseable JSON")
.option("--refresh", "First read the Codex login's usage windows and the Claude login's identity natively (up to 35 s here, 75 s with --all-hosts/--hosts; provider.usage_windows is off by default: rig config set provider.usage_windows true)")
.option("--provider <p>", "With --refresh, read only this provider (codex|claude)")
.option("--claude-token-file <path>", "With --refresh --provider claude, probe this absolute token-file reference on the selected daemon (one model turn; no token value in argv)")
.option("--credential-label <label>", "Operator label for the selected Claude token file; not provider-observed account identity")
.option("--all-hosts", "Usage windows across every registered host, as each host already holds them (off by default: rig config set provider.usage_windows true)")
.option("--hosts <ids>", "Like --all-hosts, for these registered hosts (comma-separated); this host is always included, so --hosts b --refresh also reads this daemon")
.action(async (opts: { json?: boolean; refresh?: boolean; provider?: string; allHosts?: boolean; hosts?: string; claudeTokenFile?: string; credentialLabel?: string }) => {
.action(async (opts: { json?: boolean; refresh?: boolean; provider?: string; allHosts?: boolean; hosts?: string }) => {
if (opts.provider !== undefined && opts.provider !== "codex" && opts.provider !== "claude") {
console.error(`--provider must be codex or claude (got ${opts.provider})`);
process.exitCode = 1;
return;
}
const tokenSelected = opts.claudeTokenFile !== undefined || opts.credentialLabel !== undefined;
if (tokenSelected && (!opts.claudeTokenFile || !opts.credentialLabel || !opts.refresh || opts.provider !== "claude" || opts.allHosts || opts.hosts !== undefined)) {
console.error("A token probe requires --refresh --provider claude --claude-token-file <absolute path> --credential-label <label>, on one daemon (no --all-hosts/--hosts).");
process.exitCode = 1; return;
}
if (tokenSelected) console.error("Claude token usage refresh spends one model turn; timeout or refusal does not prove no allowance was consumed.");
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
@@ -443,7 +395,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
const providers = opts.provider ? [opts.provider] : ["codex", "claude"];
const answered = await Promise.all(providers.map(async (provider) => ({
provider,
answer: await client.post<Record<string, unknown>>("/api/provider/usage/refresh", { provider, ...(tokenSelected ? { claudeToken: { label: opts.credentialLabel, tokenFile: opts.claudeTokenFile } } : {}) }, { timeoutMs: 60_000 }),
answer: await client.post<Record<string, unknown>>("/api/provider/usage/refresh", { provider }, { timeoutMs: 60_000 }),
})));
// Bare --refresh keeps main's Codex exit: Claude is read too, but where it doesn't apply on this host
// (no claude, no login, not a subscription login), or the daemon has no Claude refresh (400/404, an
@@ -506,136 +458,46 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("precheck")
.description("Whether a seat can move to a login: its in-place restart plan (nothing is stopped) and the login saved in the Codex home it runs on")
.description("Whether switching a seat to an account is safe (never offers an unsafe switch)")
.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("--stored-login", "Claude: move the seat back to its config dir's stored login")
.requiredOption("--to-account <a>", "The target account ref")
.option("--json", "Output as parseable JSON")
.action(async (opts: AccountOpts & { seat: string; 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;
return;
}
.action(async (opts: { seat: string; toAccount: string; json?: boolean }) => {
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
...(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()}`);
const qs = new URLSearchParams({ seat: opts.seat, toAccount: opts.toAccount });
const res = await client.get<{ safe: boolean; reasons?: string[] }>(`/api/provider/precheck?${qs.toString()}`);
if (handleHttpError(res, "precheck")) return;
if (opts.json) console.log(JSON.stringify(res.data));
else {
console.log(res.data.safe ? "SAFE" : `UNSAFE: ${(res.data.reasons ?? []).join(", ")}`);
if (res.data.message) console.log(res.data.message);
if (res.data.seat) printSeat(res.data.seat);
if (res.data.claude) printClaude(res.data.claude);
}
else console.log(res.data.safe ? "SAFE" : `UNSAFE: ${(res.data.reasons ?? []).join(", ")}`);
if (!res.data.safe) process.exitCode = 1; // let scripts gate on an unsafe verdict
});
cmd
.command("switch")
.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")
.description("Switch a seat to an account (precheck-gated; the daemon orchestrates the switch)")
.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("--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")
.requiredOption("--to-account <a>", "The target account ref")
.option("--force-unsafe", "Override a non-stranding precheck failure (still refuses to strand a live conversation)")
.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
home's current login as before-switch-<time>, activates the chosen one there (rig auth switch), and restarts the seat in place
with the same conversation, pane and launch settings. If the current login can't be saved first, nothing changes. Every seat
on that home shares its login. Peers aren't restarted: each keeps its current login only until its next token refresh, when
Codex stops it with "signed in to another account" until it's restarted, so the report prints the rig seat restart <peer>
--keep-conversation --reason "..." to paste for each. Signing in again there would replace the login you just switched to.
One switch runs at a time per home; a switch that waited checks the seat again before it changes anything. The account
isn't verified until the seat completes a tool turn: run the printed rig seat restart --keep-conversation --verify
--expect-account. An uncertain restart is reported, never retried.
Codex limits:
- Linux only: the in-place restart reads the running process from /proc, so elsewhere it refuses before anything changes.
- Same home: the login must already be saved in the home the seat runs on; the seat doesn't move to another home.
A Claude seat keeps its config dir and gets its own subscription token: --to-account <your label> --token-file <absolute path
to a 0600 file holding the token>. Only the path is kept, as the seat's reference; the launch reads the file as Claude starts,
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
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.`)
.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;
return;
}
.action(async (opts: { seat: string; toAccount: string; forceUnsafe?: boolean; json?: boolean }) => {
const client = await getClient(getDeps());
if (!client) {
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();
try {
res = await client.post<SwitchResponse["data"]>("/api/provider/switch", {
seat: opts.seat,
...target,
forceUnsafe: opts.forceUnsafe ?? false,
}, { timeoutMs: SWITCH_TIMEOUT_MS });
} catch (err) {
if (!(err instanceof DaemonTimeoutError)) throw err;
// The daemon may still be switching: a timeout is neither a failure nor a reason to send it again.
res = { status: 200, data: { outcome: "unknown", reasons: ["switch_outcome_unknown"],
message: `The CLI timed out waiting for the daemon. The switch may still be running, or queued behind another switch on the same Codex home; a queued switch hasn't changed anything yet. `
+ `For a Codex seat, it has restarted the seat once rig seat restart ${opts.seat} --keep-conversation --plan shows a lastRestart that finished after ${startedAt}. `
+ `Until then an eligible plan, or an older lastRestart, doesn't mean it ended, so don't run the switch again: a second one would queue behind it. `
+ `For a Claude seat, rig seat status ${opts.seat} shows its token reference and whether it runs; a second switch would wait for this one.` } };
}
const res = await client.post<{ outcome?: string; reasons?: string[] }>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
forceUnsafe: opts.forceUnsafe ?? false,
});
if (handleHttpError(res, "switch")) return;
if (opts.json) console.log(JSON.stringify(res.data));
else {
const { outcome, reasons, message, seat } = res.data;
console.log(reasons?.length ? `${outcome ?? "unknown"}: ${reasons.join(", ")}` : outcome ?? "unknown");
if (message) console.log(message);
if (seat?.home) {
console.log(`home: ${seat.home}${seat.loginChanged ? ` (now "${seat.account}")` : ""}`);
if (seat.undo) {
console.log(`to restore the home's saved credential bytes: ${seat.undo}`);
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 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.conversation) console.log(`conversation: ${seat.conversation}`);
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
}
if (seat) {
if (seat.restart?.recoveryCommand) console.log(`recovery: ${seat.restart.recoveryCommand}`);
if (!seat.home && seat.restart?.remainingStep) console.log(seat.restart.remainingStep);
printSeat({ peers: seat.peers });
if (seat.next) console.log(`next: ${seat.next}`);
}
}
if (res.data.outcome !== "rebind_in_progress" && res.data.outcome !== "succeeded") process.exitCode = 1;
else console.log(res.data.outcome ?? "unknown");
if (res.data.outcome === "failed_safely") process.exitCode = 1;
});
// Slice 18 — subscription move reports: what the agent that moved a Codex home reports, kept as queue items tagged
+41 -210
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import { randomBytes } from "node:crypto";
import { Command, InvalidArgumentError } from "commander";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, type DaemonResponse } from "../client.js";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
@@ -10,7 +10,7 @@ import { enumArg, positiveIntArg } from "../cli-error.js";
import type { StatusDeps } from "./status.js";
import { resolveContextRef } from "../context-resolve.js";
import { shellQuote } from "../cross-host-executor.js";
import { loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { loadHostBindings } from "../host-bindings.js";
import { omittedReadField, readView } from "../read-view.js";
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
@@ -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).",
};
@@ -334,8 +332,7 @@ async function withClient<T>(
// render owns the 3-part fact/consequence/action + the io exit (response-integrity
// contract). One render authority, layered context — never a swallowed exit.
const where = hostContext ? ` (routing to host '${hostContext}')` : "";
const what = err instanceof DaemonTimeoutError ? "queue request got no answer in time" : "queue transport failure";
console.error(`${what}${where}: ${err.message}`);
console.error(`queue transport failure${where}: ${err.message}`);
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
}
throw err;
@@ -443,15 +440,6 @@ function remoteHostOf(hostId: string | undefined): string | undefined {
return hostId && hostId !== "local" ? hostId : undefined;
}
/** A cross-host queue write waits for the selected daemon's own answer. That daemon gives the remote 10 s
* (`QUEUE_FORWARD_TIMEOUT_MS` in the daemon's routes/queue.ts), so waiting only the client's 5 s default
* turns a slow remote's answer into an unknown outcome. */
const CROSS_HOST_QUEUE_WRITE_TIMEOUT_MS = 15_000;
function crossHostWriteOptions(hostId: string | undefined): { timeoutMs: number } | undefined {
return remoteHostOf(hostId) ? { timeoutMs: CROSS_HOST_QUEUE_WRITE_TIMEOUT_MS } : undefined;
}
/** The daemon's own rule (`resolvesToLocalHost`): a `--host` naming the selected
* daemon's selfHostId took the local path, so its receipt must not call the row
* remote. Read only for a receipt; an unreadable id leaves the host unconfirmed. */
@@ -466,122 +454,6 @@ async function settleReceiptHost(client: DaemonClient, receipt: ReceiptContext):
return selfHostId === undefined ? { ...receipt, remoteHostUnconfirmed: true } : receipt;
}
/** How long the one read-back of a create with an unknown outcome waits for its answer. */
const CREATE_READ_BACK_TIMEOUT_MS = 10_000;
/** What a create sent, for telling its row from another with the same ID. `claimedSource` is the body's source, which the
* daemon records only when the request carries no sender; `remoteHost` is the host it was sent to with `--host`. */
interface SentCreate { destinationSession: string; claimedSource: string | undefined; body: string; remoteHost: string | undefined }
/** The daemon's host id: null when it reports none, undefined when it can't be read. */
async function daemonSelfHostId(client: DaemonClient): Promise<string | null | undefined> {
try {
const health = await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 });
if (health.status !== 200) return undefined;
return typeof health.data?.selfHostId === "string" && health.data.selfHostId ? health.data.selfHostId : null;
} catch {
return undefined;
}
}
/** The source the daemon this client reached stores for the create, by its own rules: the sender this client sent, trimmed and
* without a trailing `@<that daemon's host id>` (canonicalSenderSession), or with no sender the body's claimed source, trimmed.
* A forwarded create's source then gains the forwarding daemon's host id (stampSelfHostSuffix), unless its origin was marked
* unknown. Undefined when that host id is needed and can't be read. */
async function storedSourceOf(client: DaemonClient, sent: SentCreate): Promise<string | undefined> {
const { session, originUnknown } = await client.senderIdentity();
const source = (session ?? sent.claimedSource)?.trim();
if (!source) return undefined;
const parts = source.split("@");
const canonicalizes = session !== undefined && parts.length === 3 && parts.every(Boolean);
const stamps = sent.remoteHost !== undefined && !originUnknown;
if (!canonicalizes && !(stamps && parts.length === 2)) return source;
const selfId = await daemonSelfHostId(client);
if (selfId === undefined) return undefined;
const canonical = canonicalizes && parts[2] === selfId ? `${parts[0]}@${parts[1]}` : source;
// The daemon forwards only to another host (resolvesToLocalHost): a target naming its own id is a local create, unstamped.
const forwarded = stamps && sent.remoteHost !== selfId;
return forwarded && selfId && canonical.split("@").length === 2 ? `${canonical}@${selfId}` : canonical;
}
/** Which field shows a stored row isn't this create, by the rule the daemon applies to a create whose ID already exists
* (the same destination and source, compared exactly), plus the body, which that rule reports as not saved. "unread" when
* the source this create would have can't be established. Null when the row is this create. */
function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSource: string | undefined): "destination" | "unread" | "source" | "body" | null {
if (row.destinationSession !== sent.destinationSession) return "destination";
if (storedSource === undefined) return "unread";
if (row.sourceSession !== storedSource) return "source";
if (row.body !== sent.body) return "body";
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;
let reader = client;
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
if (remote) {
const registry = loadHostRegistry();
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})` };
}
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { error: `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);
if (res.status === 200 && res.data?.qitemId === qitemId) {
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
if (differs === "unread") {
return { row: null, note: `Row ${qitemId}${where} exists, but this CLI couldn't read this daemon's host id to tell whether its source is this create's, so the outcome is still unknown.` };
}
if (differs) {
return { row: null, note: `Row ${qitemId}${where} exists but isn't this create (its ${differs} differs), so the outcome is still unknown. Don't reuse this ID for other work.` };
}
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).` };
}
if (res.status === 404) return { row: null, note: `No row ${qitemId}${where} yet (read back once by its ID); it may still commit.` };
return { row: null, note: `Reading row ${qitemId}${where} back answered ${res.status}, so the outcome is still unknown.` };
} catch (err) {
return { row: null, note: `Reading row ${qitemId}${where} back didn't answer (${(err as Error).message}), so the outcome is still unknown.` };
}
}
async function printWriteResult(client: DaemonClient, json: boolean, body: unknown, status: number, receipt: ReceiptContext | null): Promise<void> {
const settled = receipt && status < 400 ? await settleReceiptHost(client, receipt) : null;
const projected = settled ? buildWriteReceipt(body, settled) : null;
@@ -790,19 +662,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);
@@ -1068,7 +927,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const receipt = writeReceipt(opts.full, {
verb: "create", nudgeSuppressed: opts.nudge === false, idGiven: opts.id !== undefined, remoteHost: remoteHostOf(hostResolved.hostId),
});
const res: DaemonResponse<Record<string, unknown>> | Error = await client.post<Record<string, unknown>>("/api/queue/create", {
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
...(managedSource === undefined ? { sourceSession: source } : {}),
qitemId,
destinationSession: hostResolved.destination,
@@ -1089,61 +948,20 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// OPR.0.4.6.MH3 FR-1: the out-of-band host envelope (omitted for
// plain local writes — the local path stays byte-identical).
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
const sent: SentCreate = { destinationSession: hostResolved.destination, claimedSource: managedSource === undefined ? source : undefined, body: resolvedBody,
remoteHost: remoteHostOf(hostResolved.hostId) };
// Every create that's answered with a row ends here: its warning, --verify's delivery, and the output.
const finish = async (created: Record<string, unknown>, status: number) => {
const createWarning = created?.createWarning as { code?: unknown; message?: unknown } | undefined;
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 })
: 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;
}
await printWriteResult(client, opts.json ?? false, created, status, receipt);
};
if (res instanceof Error) {
}).catch((error: unknown) => {
// A pre-header disconnect can follow a committed create. Preserve the
// typed error, but do not let the shared renderer assert nondelivery.
if (res instanceof DaemonConnectionError) res.writeOutcome = "unknown";
// No answer in time, a lost connection, or a response lost or unreadable after its headers: the create may have
// committed, so read it back once by its ID.
if (res instanceof DaemonTimeoutError || res instanceof DaemonConnectionError || res instanceof DaemonResponseError) {
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
console.error(late.note);
if (late.row) {
await finish(late.row, 200);
return;
}
}
if (res instanceof DaemonConnectionError || res instanceof DaemonResponseError) {
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
if (error instanceof DaemonConnectionError || error instanceof DaemonResponseError) {
// Keep the shared typed error/exit path, including --json, while
// supplying the identity missing from an unreadable/late response.
res.message += ` ${recovery}`;
error.message += ` ${recovery}`;
}
throw res;
}
throw error;
});
if (res.status >= 400 && res.data?.outcome === "indeterminate") {
// Forwarded writes may return a structured unknown outcome rather
// than throw: read the row back once from the destination before reporting it.
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
console.error(late.note);
if (late.row) {
await finish(late.row, 200);
return;
}
// Preserve that failure and expose the same recovery ID.
// than throw. Preserve that failure and expose the same recovery ID.
printResult(opts.json ?? false, { ...res.data, qitemId, recovery }, res.status);
return;
}
@@ -1151,7 +969,20 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
if (refusal) console.error(`No row was created: ${refusal}`);
const hint = hostSubstitutionHint(res.status, res.data, opts.destination, opts.host, hostResolved, `, keeping --id ${shellQuote(qitemId)}`);
if (hint) console.error(hint);
await finish(res.data, res.status);
const createWarning = res.data?.createWarning as { code?: unknown; message?: unknown } | undefined;
if (res.status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
console.error(`Warning: ${createWarning.message}`);
}
if (opts.verify && res.status < 400) {
const created = res.data;
const qitemId = typeof created.qitemId === "string" ? created.qitemId : null;
const delivery = qitemId
? await waitForDeliveryOutcome(client, qitemId, 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, persisted: true, delivery }, res.status, receipt);
return;
}
await printWriteResult(client, opts.json ?? false, res.data, res.status, receipt);
}, hostResolved.hostId !== undefined, hostResolved.hostId);
});
@@ -1164,7 +995,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`, {});
@@ -1182,7 +1013,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`, {
@@ -1194,11 +1025,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("update <qitemId>")
.description("Append a note and/or mutate qitem state. A note without --state never changes row state. state=done REQUIRES --closure-reason (one of: handed_off_to, denied, canceled, no-follow-on, escalation, superseded). To cancel a row: --state done --closure-reason canceled. To wait for another qitem, use rig queue block <id> --on <child>. Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
.description("Append a note and/or mutate qitem state. A note without --state never changes row state. state=done REQUIRES --closure-reason (one of: handed_off_to, denied, canceled, no-follow-on, escalation, superseded). To wait for another qitem, use rig queue block <id> --on <child>. Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
.option("--actor <session>", "(deprecated, ignored) the actor is derived from the seat env (X-OpenRig-Session); P21 I3 made the update route derive it from the transport header")
.option("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-reason <reason>", "Required for state=done; also 'superseded' on state=canceled (with --closure-target = the successor) records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
@@ -1243,7 +1074,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`, {
@@ -1307,7 +1138,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`, {
@@ -1331,8 +1162,8 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// in-progress on the SAME item) — never a closure, never a new owner.
cmd
.command("resolve <qitemId>")
.description("Resolve a qitem blocked on a human seat or a typed gate (external:, fold:, auth:): records the decision or satisfaction evidence durably, unparks blocked -> in-progress, and nudges the owner. Non-closure; qitem dependencies are not resolved by this command.")
.requiredOption("--decision <text>", "The human decision or typed-gate satisfaction evidence (non-empty; lands in transition_note + the audit row)")
.description("Resolve a leg-1 parked qitem (state=blocked on a human seat): records the decision text durably in queue_transitions, unparks blocked -> in-progress, and nudges the owner. Non-closure.")
.requiredOption("--decision <text>", "The human's decision text (non-empty; lands in transition_note + the audit row)")
.option("--actor <session>", "(deprecated, ignored) resolver is derived from the seat env (X-OpenRig-Session)")
.option("--bearer <token>", "Operator bearer token for the mission-control write gate (or set OPENRIG_AUTH_BEARER_TOKEN; loopback daemons without a configured bearer need none)")
.option("--no-notify", "Skip the best-effort owner nudge (the unpark still commits)")
@@ -1347,7 +1178,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) => {
@@ -1407,7 +1238,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
@@ -1459,7 +1290,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
targetRepo: opts.targetRepo,
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
}, crossHostWriteOptions(hostResolved.hostId));
});
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
if (hint) console.error(hint);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
@@ -1511,7 +1342,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.
@@ -1561,7 +1392,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
targetRepo: opts.targetRepo,
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
}, crossHostWriteOptions(hostResolved.hostId));
});
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
if (hint) console.error(hint);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
@@ -1960,7 +1791,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}`);
+9 -20
View File
@@ -101,15 +101,12 @@ function makeStdout(): Stdout {
return { write: (text: string) => process.stdout.write(text) };
}
/** The lifecycle compiler names a mission's workflow by `metadata.name`, so the scaffold records it (quoted: a name
* only excludes whitespace and path separators). */
const missionManifest = (name: string) => `schema: openrig.mission/v0alpha1
const MISSION_MANIFEST = `schema: openrig.mission/v0alpha1
kind: mission
# The project proof rollup counts only missions whose status is active or
# release-candidate. A new mission is planned, so it stays outside the rollup
# until you change this to active (or release-candidate).
metadata:
name: ${JSON.stringify(name)}
status: planned
composition:
mission_markdown:
@@ -347,7 +344,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 +799,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);
@@ -961,7 +950,7 @@ function buildMissionCreateCommand(): Command {
// New scaffolds author SPEC.md; existing README-backed nodes are never rewritten.
const readmePath = path.join(absPath, "SPEC.md");
fs.writeFileSync(readmePath, readmeBody, "utf8");
fs.writeFileSync(path.join(absPath, "mission.yaml"), missionManifest(name), "utf8");
fs.writeFileSync(path.join(absPath, "mission.yaml"), MISSION_MANIFEST, "utf8");
const progressPath = path.join(absPath, "PROGRESS.md");
fs.writeFileSync(progressPath, progressBody, "utf8");
const capabilityDeltaPath = capabilityDeltaBody
@@ -1068,7 +1057,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 +1065,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 +1204,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 +2144,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());
+8 -254
View File
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
@@ -27,9 +26,6 @@ interface SeatStatusResponse {
pod_id: string | null;
pod_namespace: string | null;
runtime: string | null;
/** Slice 16: an open runtime switch, or one recovery stopped short of finishing. */
runtimeSwitch?: { id: string; phase: string; from: string; to: string; committed: boolean; startedAt: string; attention: string | null; outcome: string | null };
compaction?: { stage: string; detail: string };
current_occupant: string | null;
typingGuard?: { desired: boolean; effective: boolean; pending: boolean; heldCount: number;
desiredMode?: string; effectiveMode?: string; desiredLevel?: string; effectiveLevel?: string; };
@@ -54,21 +50,13 @@ interface SeatStatusResponse {
account?: {
summary: string;
overrides: string[];
openRigAdds: { claudeAllow: string[]; claudeAsk: string[]; claudeDeny?: string[]; notes: string[] };
openRigAdds: { claudeAllow: string[]; claudeAsk: string[]; notes: string[] };
unknown: string[];
controls: string[];
advisories?: string[];
};
};
session_status: string | null;
launch_cwd?: string | null;
runtime_home?: {
variable: string;
home: string | null;
source: "member" | "rig" | "daemon" | "native_default";
ignored?: { source: string; value: 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;
@@ -160,8 +148,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;
@@ -199,18 +185,6 @@ function printHuman(status: SeatStatusResponse): void {
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)}`);
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 === "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 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) {
const g = status.typingGuard;
const mode = g.effectiveLevel ?? g.effectiveMode ?? (g.effective ? "on" : "off");
@@ -222,7 +196,6 @@ function printHuman(status: SeatStatusResponse): void {
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
}
console.log(`Session: ${display(status.session_status, "unknown")}`);
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
const reason = sessionReason(status.session_reason);
if (reason) console.log(`Reason: ${reason}`);
if (status.permissions?.account) {
@@ -230,10 +203,8 @@ function printHuman(status: SeatStatusResponse): void {
const a = p.account!;
console.log("Permissions, planned for the next fresh managed launch (not observed):");
console.log(` ${a.summary}`);
for (const advisory of a.advisories ?? []) console.log(` ${advisory}`);
if (a.overrides.length) console.log(` This seat's choice is used instead of the ${a.overrides.join(" and the ")}.`);
for (const note of a.openRigAdds.notes) console.log(` ${note}`);
if (a.openRigAdds.claudeDeny) console.log(` Claude member rules (planned): ${a.openRigAdds.claudeDeny.length} denies; exact lists are in --json.`);
if (a.openRigAdds.claudeAllow.length) console.log(" The exact allowance and ask lists are in --json, under permissions.account.openRigAdds.");
console.log(` Last launch arguments (observed): ${p.lastLaunchArguments?.value ?? "unknown"}${p.lastLaunchArguments?.approvalPolicy ? `; approval=${p.lastLaunchArguments.approvalPolicy}` : ""}`);
for (const item of a.unknown) console.log(` Unknown: ${item}`);
@@ -279,12 +250,6 @@ function printHuman(status: SeatStatusResponse): void {
console.log(`Handover result: ${display(status.handover_result)}`);
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
console.log(`Handover at: ${display(status.handover_at)}`);
if (status.runtimeSwitch) {
const sw = status.runtimeSwitch;
console.log(`Runtime switch: ${sw.from} -> ${sw.to} (switch ${sw.id}, since ${sw.startedAt}): ${sw.committed
? `committed; the ${sw.to} occupant holds the seat` : `not committed; the ${sw.from} occupant holds the seat`}, ${sw.phase}`);
if (sw.attention) console.log(` Needs attention: ${sw.attention} Finish it with rig seat set-runtime ${status.seat_ref} --recover.`);
}
}
function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
@@ -341,11 +306,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 {
@@ -395,35 +355,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")
@@ -611,7 +542,7 @@ rig seat handover, THEN retarget the view. Examples:
cmd
.command("clear-attention")
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
.option("--reason <text>", "Attest startup or subset-restore attention, or that a prompt the latest hook names is gone; cannot bypass full-restore or pane-identity checks")
.option("--reason <text>", "Attest startup or subset-restore attention; cannot bypass full-restore or pane-identity checks")
.option("--json", "JSON output for agents")
.description("Reconcile seat attention using the checks for its attention class")
.addHelpText("after", `
@@ -620,11 +551,7 @@ Examples:
rig seat clear-attention dev-impl@my-rig --reason "founder re-authed, confirmed live"
rig seat clear-attention dev-impl@my-rig --json
--reason can acknowledge startup-status and subset-restore attention. It can
also say a prompt is gone: when the seat's latest hook still says it waits on a
person (a permission prompt declined or answered with no hook after) and sends
are refused, --reason "<what you saw>" records the prompt cleared. Each send
still checks the pane for a prompt it recognizes. It does
--reason can acknowledge startup-status and subset-restore attention. It does
not bypass full-restore continuity or pane-identity checks. Acknowledgment alone
does not prove that the original conversation resumed.
If the recorded native token needs correction and you know the actual token:
@@ -686,9 +613,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;
@@ -766,47 +692,14 @@ identity remains unverified; a version number alone cannot clear it.
cmd
.command("set-permissions")
.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("--mode <mode>", "floor, full_bypass, inherit, or a Claude mode supported by the bound managed launch context")
.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;
}
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 => {
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;
}
.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.")
.action(async (seat: string, opts: { mode: string; reason: string; operator?: string; json?: boolean }) => {
await runLifecycleVerb("set-permissions", seat, { mode: opts.mode, reason: opts.reason, operator: opts.operator }, opts, data => {
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"]));
@@ -911,145 +804,6 @@ resume/successor launch reads it at call time. Examples:
});
});
cmd
.command("set-runtime")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--runtime <runtime>", "codex or claude-code")
.option("--reason <text>", "Why the seat moves; recorded with the switch")
.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("--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")
.option("--confirm <switch-id>", "For the staged successor only: confirm the handover (with --note)")
.option("--note <text>", "With --confirm: what you'll do first")
.option("--recover", "Finish an interrupted switch from its record")
.option("--json", "JSON output for agents")
.description("Move a live Codex or Claude Code seat to the other runtime, successor first")
.addHelpText("after", `
The seat keeps its address, connections, queue rows and claims. Its successor
starts on the target runtime beside the current occupant, reads a handover, and
confirms it; only then does the seat move to it and the old runtime retire.
Without a confirmation in time, nothing changes and the current occupant keeps
the seat.
Costs: a new conversation (history isn't converted, only the handover carries
over); the seat's pane and scrollback change; the team file isn't rewritten (a
later converge reports a deferred change_runtime); the source runtime's model,
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.
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
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit"
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; 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.");
process.exitCode = 1;
return;
}
if (opts.runtime !== undefined && !opts.reason?.trim()) {
console.error("Pass --reason with --runtime; it is recorded with the switch.");
process.exitCode = 1;
return;
}
if (opts.confirm !== undefined && !opts.note?.trim()) {
console.error('Pass --note with --confirm, saying what you\'ll do first.');
process.exitCode = 1;
return;
}
let confirmTimeoutSeconds: number | undefined;
if (opts.confirmTimeout !== undefined) {
confirmTimeoutSeconds = Number(opts.confirmTimeout);
if (!Number.isInteger(confirmTimeoutSeconds) || confirmTimeoutSeconds < 10 || confirmTimeoutSeconds > 3600) {
console.error("--confirm-timeout must be a whole number of seconds from 10 to 3600.");
process.exitCode = 1;
return;
}
}
const deps = getDeps();
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(daemon)) return;
const client = deps.clientFactory(getDaemonUrl(daemon));
const base = `/api/seat/set-runtime/${encodeURIComponent(seat)}`;
let res;
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 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 (!(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.",
guidance: `Check it before anything else: rig seat status ${shellQuote(seat)}` };
if (opts.json) console.log(JSON.stringify(unknown, null, 2));
else printSeatError(unknown, unknown.message);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
process.exitCode = res.status >= 500 ? 2 : res.status >= 400 || res.data["recoveryPending"] === true ? 1 : 0;
return;
}
if (res.status >= 400) {
printSeatError(res.data as unknown as SeatStatusError, `Runtime switch failed (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
const data = res.data;
if (opts.confirm !== undefined || opts.recover) {
console.log(String(data["message"] ?? data["outcome"] ?? "Done."));
return;
}
const seatInfo = data["seat"] as { ref?: string } | undefined;
const name = seatInfo?.ref ?? seat;
if (data["changed"] === false) {
console.log(`${name} already runs ${String(data["to"])}. Nothing changed.`);
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 console.log(`Switched ${name} from ${String(data["from"])} to ${String(data["to"])} (switch ${String(data["switchId"])}).`);
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];
if (!setting || (key === "advisor" && data["to"] !== "claude-code") || (key === "codexConfigProfile" && data["to"] !== "codex")) continue;
console.log(` ${title}: ${setting.value ?? "none"} (${label(setting.source)})`);
}
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 ${String(data["from"])}: ${dropped.map((d) => `${d.setting} ${d.value}`).join("; ")}.`);
for (const line of (data["notRun"] as string[] | undefined) ?? []) console.log(`Not run: ${line}`);
if (data["dryRun"]) {
console.log("Costs:");
for (const cost of (data["costs"] as string[] | undefined) ?? []) console.log(` - ${cost}`);
} else {
const confirmation = data["confirmation"] as { note: string } | undefined;
if (confirmation) console.log(`The successor confirmed the handover: "${confirmation.note}"`);
console.log(`Queue: ${String(data["openRows"] ?? 0)} open rows stay addressed to the seat; ${String(data["claimsMoved"] ?? 0)} claims moved to the successor.`);
}
for (const warning of (data["warnings"] as string[] | undefined) ?? []) console.warn(`Warning: ${warning}`);
if (data["recoveryPending"] === true) process.exitCode = 1;
});
cmd
.command("set-cwd")
.argument("<seat>", "Canonical session name or logical seat ref")
+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 -9
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.
+1 -22
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;
@@ -246,24 +246,6 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
.command("audit")
.description("Read-only skill provenance and freshness audit")
.option("--json", "JSON output")
.addHelpText("after", `
What it reports: each copy's declared provenance (owner, source_ref or version), whether its verified date is within
90 days and names an evidence source (stale_verified, bare_verified; state "stale" in --json), its content hash, and
whether it is a byte-identical copy of a bundled plugin skill. "stale" is a date finding. Two copies of one skill with
different hashes have different bytes; that alone doesn't say which is newer or that either was edited on purpose.
What it doesn't say: whether a copy is safe to refresh or overwrite. For a managed copy in a working folder, run the
read-only loadout check first (no --apply):
rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json
It reports each copy as current; stale, still OpenRig's last projection, so --apply can refresh it; conflicting;
shadowed; or missing. Read the detail of conflicting and shadowed: conflicting can mean the bytes differ or that the
target couldn't be inspected, which leaves any difference unknown, and a confirmed difference doesn't prove a
deliberate local edit. A seat's own skills, kept outside the project (0.7.1 and later), are listed with where they're
stored by rig skill loadout --seat <session|self> --json; that read doesn't classify them.
A global or untracked copy has no install record, so its origin stays unknown: ask the skill's owner before replacing
it. Don't move global skills into a project just to get a record.
`)
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -331,9 +313,6 @@ it. Don't move global skills into a project just to get a record.
} else {
console.log("PASS: all active skills have provenance and verified freshness");
}
console.log("Refreshing a copy? This audit doesn't say whether that's safe. For a managed copy, run the read-only "
+ "rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json first; "
+ "rig skill audit --help says what its answers mean.");
});
return cmd;
+2 -71
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;
@@ -514,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)")
+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) {
+2 -39
View File
@@ -6,8 +6,7 @@ import type { DaemonClient } from "../client.js";
* Derived each time from existing records, never stored: the automatic path's disposition (accepted, or
* deferred to intake), intake's classification, and each linked work row's current state and closure, followed through handoffs.
* Classification, queue persistence, custody and completion stay distinct, and a closed row is reported
* as its owner's outcome, not as proof a fix shipped. Readers' recorded actions (act, decline, duplicate) are shown beside
* it; when nothing automatic linked work, the newest reader-linked work decides the stage.
* as its owner's outcome, not as proof a fix shipped.
*/
type Get = Pick<DaemonClient, "get">;
@@ -18,7 +17,6 @@ type Classification = { streamItemId: string; classifierSession: string; classif
type QueueRow = { qitemId: string; destinationSession: string; state: string; closureReason: string | null; closureTarget: string | null;
resolution: string | null; evidenceRef: string | null; tsUpdated: string };
type Work = { qitemId: string; owner?: string; state: string; closedToward?: string | null; handedOnFrom?: string[]; unknown?: string };
type ReaderAction = { actionId?: string; readerSession: string; action: string; workQitemId: string | null; duplicateOf: string | null; reason: string | null; actedAt: string };
const OPEN = ["pending", "in-progress", "blocked"];
const HOPS = 8;
@@ -48,7 +46,7 @@ async function work(client: Get, qitemId: string): Promise<Work> {
}
}
async function routed(client: Get, streamItemId: string) {
export async function streamOutcome(client: Get, streamItemId: string) {
const id = encodeURIComponent(streamItemId);
const [dispositionRes, classificationRes] = await Promise.all([
client.get<unknown>(`/api/projects/dispositions/${id}`),
@@ -95,38 +93,3 @@ async function routed(client: Get, streamItemId: string) {
: `Opened ${linked} for ${d.ownerSession}${moved}; it's ${row.state}${row.owner && row.owner !== d.ownerSession ? ` and now with ${row.owner}` : ""}.`,
accepted, application: d.applied, classification, work: rows, note };
}
/** Stages where no automatic work is live (none linked, or its effect ended with intake); a reader's linked work decides them. */
const READER_DECIDES = ["awaiting_intake", "deferred_to_intake", "decided_by_intake", "no_action", "with_intake", "unavailable"];
export async function streamOutcome(client: Get, streamItemId: string) {
const [base, actionsRes] = await Promise.all([routed(client, streamItemId),
client.get<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/actions`)]);
if (actionsRes.status !== 200 || !object(actionsRes.data) || !Array.isArray(actionsRes.data.current)) return { ...base, readers: "unavailable" };
const current = actionsRes.data.current as ReaderAction[];
if (!current.length) return base;
const readers = current.map(a => ({ reader: a.readerSession, action: a.action, at: a.actedAt, ...(a.workQitemId ? { work: a.workQitemId } : {}),
...(a.duplicateOf ? { duplicateOf: a.duplicateOf } : {}), ...(a.reason ? { reason: a.reason } : {}) }));
// Newest first; within the same millisecond, record order (the monotonic action id) decides.
const acted = current.filter(a => a.action === "act" && a.workQitemId)
.sort((a, b) => b.actedAt.localeCompare(a.actedAt) || (b.actionId ?? "").localeCompare(a.actionId ?? ""));
const auto = READER_DECIDES.includes(base.stage) ? undefined : (base as { work?: Work[] }).work?.[0];
// Work is compared by its current row, handoffs followed, so one handed-on chain isn't parallel work. A link whose
// current row is unknown keeps its own id.
const followed = new Map<string, Work>();
for (const id of new Set(acted.map(a => a.workQitemId!))) followed.set(id, await work(client, id));
const key = (w: Work, linkedId: string) => w.unknown ? linkedId : w.qitemId;
const lineages = new Set([...[...followed].map(([id, w]) => key(w, id)), ...(auto ? [key(auto, auto.handedOnFrom?.[0] ?? auto.qitemId)] : [])]);
const parallel = lineages.size > 1 ? { parallelWork: [...lineages] } : {};
if (!acted.length || !READER_DECIDES.includes(base.stage)) return { ...base, readers, ...parallel };
// Nothing automatic linked work: the newest reader-linked work is what the poster follows.
const by = acted[0]!;
const row = followed.get(by.workQitemId!)!;
const moved = row.handedOnFrom ? `, handed on to ${row.qitemId}` : "";
const why = `${(row.unknown ?? "").replace(/_/g, " ")}${row.closedToward ? `; closed toward ${row.closedToward}` : ""}`;
return { ...base, readers, ...parallel, work: [row],
stage: row.unknown ? "work_unknown" : OPEN.includes(row.state) ? "work_open" : "work_closed",
summary: row.unknown ? `${by.readerSession} linked ${by.workQitemId}${moved}; its current state is unknown (${why}).`
: `${by.readerSession} linked ${by.workQitemId}${moved}; it's ${row.state}${row.owner && row.owner !== by.readerSession ? ` and now with ${row.owner}` : ""}.`,
...(row.unknown || OPEN.includes(row.state) ? {} : { note: "A closed work row reports its owner's outcome; it isn't proof that a fix shipped." }) };
}
-23
View File
@@ -296,28 +296,5 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
});
});
cmd
.command("act <streamItemId>")
.description("Record what you did about a stream item: link the work you opened or joined, decline with a reason, or mark it a duplicate")
.option("--work <qitemId>", "The queue row you opened or joined for it")
.option("--decline <reason>", "Decline it, with your reason (yours only; others may still act)")
.option("--duplicate <id>", "The queue row or stream item it repeats")
.option("--reader <session>", "The reader, when not run from a managed seat")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: { work?: string; decline?: string; duplicate?: string; reader?: string; json?: boolean }) => {
const chosen = [opts.work && "work", opts.decline && "decline", opts.duplicate && "duplicate"].filter(Boolean);
if (chosen.length !== 1) {
console.error("Give exactly one of --work, --decline or --duplicate");
process.exitCode = 1;
return;
}
const body = opts.work ? { action: "act", workQitemId: opts.work } : opts.decline ? { action: "decline", reason: opts.decline }
: { action: "duplicate", duplicateOf: opts.duplicate };
await withClient(getDeps(), async (client) => {
const res = await client.post<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/act`, { ...body, ...(opts.reader ? { reader: opts.reader } : {}) });
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+16 -48
View File
@@ -2,9 +2,9 @@
// 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 open <view> [--provider herdr|cmux] [--json]
// rig terminal views [--json]
// rig terminal status [--provider herdr|cmux|rex] [--json]
// rig terminal status [--provider herdr|cmux] [--json]
//
// `<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
@@ -17,36 +17,27 @@
// 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";
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
}
/** 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;
notes?: string[];
window?: { app: string; surface: string };
space?: { id: string; label: string };
reusedWorkspace?: { id: string; tabId: string; view: string };
/** On window failures, false means no desktop launch was attempted. */
windowAttempted?: boolean;
@@ -84,19 +75,13 @@ 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");
}
@@ -108,7 +93,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;
}
}
@@ -116,7 +101,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
export function terminalCommand(depsOverride?: TerminalDeps): Command {
const cmd = new Command("terminal").description(
"Open the OpenRig view and agent conversations in terminal tiles",
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Uses herdr when installed, otherwise the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
const getDeps = (): TerminalDeps =>
depsOverride ?? {
@@ -126,43 +111,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("--provider <name>", "herdr or cmux: use an existing workspace without opening a window; tmux requires --window")
.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> 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;
}
@@ -172,8 +141,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;
@@ -201,7 +169,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
cmd
.command("status")
.description("Show terminal provider availability + liveness (doctor)")
.option("--provider <name>", "restrict to one provider (herdr / cmux / rex)")
.option("--provider <name>", "restrict to one provider (herdr / cmux)")
.option("--session <name>", "Inspect a named Herdr session on the daemon's host")
.option("--json", "JSON output for agents")
.action(async (opts: { provider?: string; session?: string; json?: boolean }) => {
+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
+1 -7
View File
@@ -157,13 +157,7 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
return;
}
const eq = assignment.indexOf("=");
const name = assignment.slice(0, eq);
// A seat's Claude or Codex home comes from env: in the spec, which restore, handover and status all read.
if (name === "CLAUDE_CONFIG_DIR" || name === "CODEX_HOME") {
console.error(`--env ${name} isn't applied: a seat's runtime home comes from env: in its rig spec (member or rig), so a restore, a handover and rig seat status all agree. Declare it there.`);
continue;
}
envOverride[name] = assignment.slice(eq + 1);
envOverride[assignment.slice(0, eq)] = assignment.slice(eq + 1);
}
const envOverrideBody = Object.keys(envOverride).length > 0 ? envOverride : undefined;
if (envOverrideBody && sourceLooksLikeTopology(source)) {
+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 -3
View File
@@ -788,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",
+6 -36
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 {
@@ -72,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}`;
@@ -368,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);
@@ -462,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([
@@ -917,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) {
@@ -1040,14 +1028,8 @@ 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 = (() => {
@@ -1336,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,
+1 -3
View File
@@ -40,9 +40,7 @@ export async function fetchWithTimeout(
return response;
} catch (err) {
if (err instanceof FetchTimeoutError) throw err;
// Once the request is aborted, its reason is why it failed, whatever error the abort surfaced as: Node's response
// stream, cut by the abort while a body is being read, ends with a plain "aborted" error.
if ((err instanceof Error && err.name === "AbortError") || requestSignal.aborted) {
if (err instanceof Error && err.name === "AbortError") {
if (requestSignal.reason instanceof Error) throw requestSignal.reason;
if (externalSignal?.aborted) throw externalAbortError(requestSignal.reason);
throw new FetchTimeoutError(options.timeoutMessage);
+568 -2
View File
@@ -1,2 +1,568 @@
// The Codex login file operations live in the daemon, which uses them too, for `rig provider switch --seat`.
export * from "@openrig/daemon/codex-auth";
// OPR.0.4.1.29 — rig auth secret-safe core (CLI-LOCAL / daemon-free).
//
// This module operates directly on the operator's Codex auth files under CODEX_HOME and is invoked
// only from the `rig auth` CLI command — it NEVER touches the daemon, so a token can never enter the
// daemon queue / SSE stream / SQLite / event log (never-queued/never-streamed is true BY CONSTRUCTION).
//
// SECRET INVARIANTS (non-negotiable): no auth/refresh/access token value is ever returned in, or used
// to build, any human-facing string. Functions return STRUCTURED, non-secret result objects
// (presence / mode / parseability / login-state / names / counts); the auth FILE is snapshotted as a
// file (mode-guarded byte copy), never read into a printed value. CODEX_HOME defaults to $HOME/.codex
// and is env-overridable (tests point it at a fixture); no personal/operator path is baked in.
import path from "node:path";
import { userInfo } from "node:os";
/** The account's home from the OS account database. Unlike os.homedir() this does not echo
* an empty $HOME back, so a HOME="" process still lands under the real home. Empty string
* when the OS reports no home at all: the caller keeps the historical cwd-relative
* ".codex" rather than erroring. */
function accountHome(): string {
try { return userInfo().homedir ?? ""; } catch { return ""; }
}
import fs from "node:fs";
import { spawnSync } from "node:child_process";
/** Strict profile-name whitelist: alnum-led, then [A-Za-z0-9._-], 1..64 chars. Excludes /, \, ~,
* leading dot, whitespace, control, and shell metacharacters. Fail closed. */
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export function validateProfileName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && name.length <= 64 && PROFILE_NAME_RE.test(name);
}
export interface CodexAuthPaths {
/** Codex state dir — CODEX_HOME or $HOME/.codex. */
codexHome: string;
/** Saved-profile dir (0700). */
profileDir: string;
/** Active auth file the Codex CLI reads (0600). */
activeAuth: string;
/** Product-native seat->profile metadata registry (0600). */
registryPath: string;
}
/** Resolve the Codex paths from an env map (default process.env). CODEX_HOME wins; else $HOME/.codex. */
export function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): CodexAuthPaths {
const home = typeof env.HOME === "string" && env.HOME.length > 0 ? env.HOME : accountHome();
const codexHome =
typeof env.CODEX_HOME === "string" && env.CODEX_HOME.length > 0
? env.CODEX_HOME
// HOME unset/empty (cron, systemd, some CI/container shells) must not yield a
// cwd-relative ".codex" — auth files stay under the account's home.
: path.join(home, ".codex");
return {
codexHome,
profileDir: path.join(codexHome, "auth-profiles"),
activeAuth: path.join(codexHome, "auth.json"),
registryPath: path.join(codexHome, "auth-seat-registry.tsv"),
};
}
// --- secret-safe fs helpers (no contents ever read into a returned/printed value) ---
function lstatSafe(p: string): fs.Stats | null {
try {
return fs.lstatSync(p);
} catch {
return null;
}
}
function isDir(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isDirectory();
}
function isFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isFile();
}
/** Octal permission string (e.g. "600") or null when the path is absent/unstatable. */
function fileModeOctal(p: string): string | null {
const st = lstatSafe(p);
if (st === null) return null;
return (st.mode & 0o777).toString(8).padStart(3, "0");
}
/** A profile file is safe iff it exists, is a regular file (not a symlink — which could redirect us
* out of the profile dir), has link count 1 (a hardlink shares an inode that may live outside the
* profile dir), and lives directly in profileDir. */
function isSafeProfileFile(p: string, profileDir: string): boolean {
const st = lstatSafe(p);
if (st === null || st.isSymbolicLink() || !st.isFile() || st.nlink !== 1) return false;
return path.dirname(p) === profileDir;
}
/** True iff `dir` really resolves INSIDE realpath(codexHome). realpath follows every symlink in the
* chain, so a symlinked parent (e.g. an auth-profiles dir pointing outside) resolves out and is
* rejected — a lexical startsWith would be fooled. Returns false if either path can't be resolved. */
function realDirContained(dir: string, codexHome: string): boolean {
try {
const root = fs.realpathSync(codexHome);
const resolved = fs.realpathSync(dir);
return resolved === root || resolved.startsWith(root + path.sep);
} catch {
return false;
}
}
/** A destination we are about to write SECRET bytes onto is safe to replace iff it is absent, or a
* regular file (not a symlink) with link count 1. A symlink redirects the write; a hardlink (nlink>1)
* shares an inode that may live outside CODEX_HOME — writing through either leaks secret bytes out. */
function destReplaceable(p: string): boolean {
const st = lstatSafe(p);
if (st === null) return true;
return !st.isSymbolicLink() && st.isFile() && st.nlink === 1;
}
/** fd-first copy of `source` onto `dest` (OPR.0.4.3.23 secret-boundary B1 hardening). Closes the
* check-then-use gap that a path-based copy leaves open: an earlier lstat guard, then a copy that
* re-resolves the SOURCE PATH, can be redirected by an inode swap in the window (a crash, or a
* concurrent legitimate rig on the same auth home) — reading the wrong inode, a torn file, or briefly
* leaving a wider-than-0600 temp (CERT FIO45-C). Path-based pre-checks cannot close that race, so the
* authoritative check moves onto the OPENED fd:
* (1) SOURCE: openSync with O_NOFOLLOW (a final-component symlink swap fails closed), then fstat ON
* THE FD and validate the opened inode (regular file, nlink === 1 — a hardlink shares an inode
* that may live outside the boundary), and — when the caller passes its earlier lstat — confirm
* dev/ino still match (the inode was not swapped in the window). Read bytes FROM the fd; the
* source path is never re-resolved after the open.
* (2) DEST: openSync the temp with O_CREAT|O_EXCL (fresh inode; never a pre-existing hardlink/symlink)
* at 0600 AT creation (removes the create-then-chmod window), fchmod on the fd to pin 0600
* regardless of umask, write the source bytes, fsync, then renameSync as the SOLE atomic publish
* (a crash BEFORE the rename leaves the prior dest byte-intact — a strength we preserve).
* The temp stays co-located in dest's dir so the rename never crosses devices. The transient byte
* buffer is the only time secret bytes touch JS memory; it is never printed and is scrubbed in
* `finally`. Caller must still have verified dest's parent containment and that dest is replaceable
* (the lstat pre-checks stay as cheap fail-fast; this fd validation is the authoritative layer).
* Exported for the fd-first / crash-safety leak-hunt tests. */
export function copyOntoFresh(
source: string,
dest: string,
expectSrc?: { dev: number; ino: number },
): boolean {
const tmp = `${dest}.tmp-${process.pid}`;
// O_NOFOLLOW fails the open closed if a final-component symlink was swapped in. (O_CLOEXEC is omitted:
// it is undefined on macOS Node and absent from @types/node, and no child process is spawned during
// this fd's synchronous lifetime, so close-on-exec would be a no-op here.)
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
let srcFd = -1;
let destFd = -1;
let data: Buffer | null = null;
try {
try {
fs.rmSync(tmp, { force: true }); // clear any stale temp so the O_EXCL create below succeeds
} catch {
/* ignore */
}
// (1) fd-first source read: open, then validate ON THE FD — never re-resolve the source path.
srcFd = fs.openSync(source, fs.constants.O_RDONLY | NOFOLLOW);
const st = fs.fstatSync(srcFd);
if (!st.isFile() || st.nlink !== 1) return false; // authoritative: regular file, single link
if (expectSrc && (st.dev !== expectSrc.dev || st.ino !== expectSrc.ino)) return false; // swapped in the window
const size = st.size;
data = Buffer.allocUnsafe(size);
let read = 0;
while (read < size) {
const n = fs.readSync(srcFd, data, read, size - read, read);
if (n === 0) break;
read += n;
}
if (read !== size) return false; // torn/truncated read (the source changed under us) → fail safe
// (2) fd-first dest temp: fresh inode (O_EXCL), 0600 AT creation, byte-copy, fsync, atomic rename.
destFd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NOFOLLOW, 0o600);
fs.fchmodSync(destFd, 0o600); // pin 0600 on the fd regardless of umask (no path chmod window)
let written = 0;
while (written < size) {
written += fs.writeSync(destFd, data, written, size - written);
}
fs.fsyncSync(destFd); // durability before the sole atomic publish
fs.closeSync(destFd);
destFd = -1;
fs.renameSync(tmp, dest); // atomic dir-entry swap; any old dest inode is unlinked, never written
return true;
} catch {
return false;
} finally {
if (srcFd >= 0) {
try {
fs.closeSync(srcFd);
} catch {
/* ignore */
}
}
if (destFd >= 0) {
try {
fs.closeSync(destFd);
} catch {
/* ignore */
}
}
if (data) data.fill(0); // scrub the secret bytes out of the transient JS buffer
try {
fs.rmSync(tmp, { force: true }); // remove any orphan temp (no-op after a successful rename)
} catch {
/* ignore */
}
}
}
function countProfiles(profileDir: string): number {
try {
return fs.readdirSync(profileDir).filter((n) => n.endsWith(".json") && isFile(path.join(profileDir, n))).length;
} catch {
return 0;
}
}
/** True iff the string contains a control character (<=0x1f or 0x7f), incl. tab/newline/CR which would
* corrupt a TSV row. Printable chars (spaces, hyphens, '@', '/') are allowed. Regex-free on purpose. */
function hasControlChar(v: string): boolean {
for (let i = 0; i < v.length; i++) {
const c = v.charCodeAt(i);
if (c <= 0x1f || c === 0x7f) return true;
}
return false;
}
export interface CodexAuthDeps {
/** Resolve the Codex login state using EXIT CODE ONLY — never the external command's stdout/stderr
* (any future codex version could print a token there). Default: spawn `codex login status` with
* stdio ignored and map the exit code. Injectable for hermetic tests. */
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
}
/** Default login-state probe: exit code only, output never captured (stdio: ignore = no leak path). */
function defaultLoginStatus(codexHome: string): "logged_in" | "not_logged_in" | "unavailable" {
const r = spawnSync("codex", ["login", "status"], {
stdio: "ignore",
env: { ...process.env, CODEX_HOME: codexHome },
});
if (r.error) return "unavailable";
return r.status === 0 ? "logged_in" : "not_logged_in";
}
export interface AuthStatusResult {
codexHome: string;
codexHomePresent: boolean;
profileDirPresent: boolean;
activeAuthPresent: boolean;
activeAuthMode: string | null;
activeAuthModeSafe: boolean | "unknown";
profileCount: number;
loginStatus: "logged_in" | "not_logged_in" | "unavailable";
}
/** Report auth-file presence/mode/safety + profile count + login-state. NEVER reads token contents. */
export function authStatus(paths: CodexAuthPaths, deps: CodexAuthDeps = {}): AuthStatusResult {
const activeAuthPresent = isFile(paths.activeAuth);
const activeAuthMode = activeAuthPresent ? fileModeOctal(paths.activeAuth) : null;
return {
codexHome: paths.codexHome,
codexHomePresent: isDir(paths.codexHome),
profileDirPresent: isDir(paths.profileDir),
activeAuthPresent,
activeAuthMode,
activeAuthModeSafe: activeAuthMode === null ? "unknown" : activeAuthMode === "600",
profileCount: countProfiles(paths.profileDir),
loginStatus: (deps.loginStatus ?? defaultLoginStatus)(paths.codexHome),
};
}
export type AuthValidateResult =
| { ok: true; name: string; path: string; mode: string }
| {
ok: false;
reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "malformed_json" | "parse_check_unavailable";
};
/** Validate a saved profile: name whitelist -> safe regular file -> 0600 -> JSON parseable. The
* parse is in-memory and the result is discarded; on failure we return a FIXED reason and never the
* JSON.parse error (its message can include a snippet of the file = a secret). */
export function authValidate(paths: CodexAuthPaths, name: string): AuthValidateResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const target = path.join(paths.profileDir, `${name}.json`);
if (lstatSafe(target) === null) return { ok: false, reason: "missing_profile" };
// Safe regular file (no symlink/hardlink) AND a profile dir that really resolves inside CODEX_HOME.
if (!isSafeProfileFile(target, paths.profileDir) || !realDirContained(paths.profileDir, paths.codexHome)) {
return { ok: false, reason: "unsafe_path" };
}
if (fileModeOctal(target) !== "600") return { ok: false, reason: "unsafe_permissions" };
let content: string;
try {
content = fs.readFileSync(target, "utf8");
} catch {
return { ok: false, reason: "parse_check_unavailable" };
}
try {
JSON.parse(content);
} catch {
// Intentionally ignore the error object — its message can echo file content (a secret).
return { ok: false, reason: "malformed_json" };
}
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSaveResult =
| { ok: true; name: string; path: string; mode: string }
| { ok: false; reason: "invalid_profile" | "not_configured" | "unsafe_path" | "io_error" };
/** Snapshot the active auth file into a named profile (0600 file in the 0700 profile dir). Byte-copy
* via copyFileSync — contents never enter JS memory; result reports name/path/mode only. */
export function authSave(paths: CodexAuthPaths, name: string): AuthSaveResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const srcStat = lstatSafe(paths.activeAuth);
if (srcStat === null || !srcStat.isFile()) return { ok: false, reason: "not_configured" };
const target = path.join(paths.profileDir, `${name}.json`);
// Refuse a symlinked profile-dir BEFORE mkdir/chmod so we never follow it out of CODEX_HOME or
// mutate the outside target's mode.
const pdStat = lstatSafe(paths.profileDir);
if (pdStat !== null && pdStat.isSymbolicLink()) return { ok: false, reason: "unsafe_path" };
try {
fs.mkdirSync(paths.profileDir, { recursive: true });
fs.chmodSync(paths.profileDir, 0o700);
} catch {
return { ok: false, reason: "io_error" };
}
// Parent must REALLY resolve inside CODEX_HOME (defeats a symlinked auth-profiles parent), and the
// existing target must not be a symlink/hardlink/non-regular file (defeats writing secret bytes
// through an inode that lives outside CODEX_HOME).
if (!realDirContained(paths.profileDir, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
if (!destReplaceable(target)) return { ok: false, reason: "unsafe_path" };
// Thread the earlier lstat so copyOntoFresh can confirm the opened fd is still the same inode
// (dev/ino) it was checked as — a swap of the active auth in the check-then-use window fails safe.
if (!copyOntoFresh(paths.activeAuth, target, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSwitchResult =
| { ok: true; name: string; activePath: string; mode: string; note: string }
| { ok: false; reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "io_error" };
/** Activate a saved profile (copy it onto the active auth at 0600). Refuses to widen perms over an
* existing unsafe active file. Byte-copy; no content echoed. */
export function authSwitch(paths: CodexAuthPaths, name: string): AuthSwitchResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const source = path.join(paths.profileDir, `${name}.json`);
const srcStat = lstatSafe(source);
if (srcStat === null) return { ok: false, reason: "missing_profile" };
if (!isSafeProfileFile(source, paths.profileDir)) return { ok: false, reason: "unsafe_path" };
// Destination guard: a symlink/non-regular OR hardlinked (nlink>1) active auth would let the copy
// write the selected profile's SECRET bytes THROUGH an inode living outside CODEX_HOME. Refuse it
// (isFile() is false for a symlink, so the old isFile-gated check silently missed both cases).
if (!destReplaceable(paths.activeAuth)) return { ok: false, reason: "unsafe_path" };
if (isFile(paths.activeAuth)) {
const m = fileModeOctal(paths.activeAuth);
if (m !== null && m !== "600") return { ok: false, reason: "unsafe_permissions" };
}
try {
fs.mkdirSync(paths.codexHome, { recursive: true });
} catch {
return { ok: false, reason: "io_error" };
}
if (!realDirContained(paths.codexHome, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
// dev/ino continuity: the opened profile fd must still be the inode we checked (swap in the window fails safe).
if (!copyOntoFresh(source, paths.activeAuth, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return {
ok: true,
name,
activePath: paths.activeAuth,
mode: "600",
note: "live Codex sessions do not switch accounts in place; restart the affected seats to pick up the new profile.",
};
}
/** List saved profile names (safe regular *.json files in the profile dir), sorted. No contents read.
* Refuses to list through a symlinked / out-of-tree profile dir (returns []), so a redirected parent
* never exposes file names from outside CODEX_HOME. */
export function authList(paths: CodexAuthPaths): string[] {
if (!realDirContained(paths.profileDir, paths.codexHome)) return [];
try {
return fs
.readdirSync(paths.profileDir)
.filter((n) => n.endsWith(".json") && isSafeProfileFile(path.join(paths.profileDir, n), paths.profileDir))
.map((n) => n.slice(0, -".json".length))
.sort();
} catch {
return [];
}
}
// --- Auth-B: seat -> profile METADATA registry (product-native; NO resume_token per orch D2) ---
/** Stated in command output + docs: a seat label is metadata, never proof of a live account. */
export const SEAT_REGISTRY_DISCLAIMER =
"Seat labels are metadata only; they do NOT prove a running session is actually using that account/profile.";
// 6 columns; NO resume_token (a print surface is the worst place to hold a secret-class token).
const SEAT_COLUMNS = ["seat", "rig", "runtime", "cwd", "auth_profile", "updated_ts"] as const;
const SEAT_HEADER = SEAT_COLUMNS.join("\t");
const SEAT_TAB_COUNT = SEAT_COLUMNS.length - 1;
export interface SeatRow {
seat: string;
rig: string;
runtime: string;
cwd: string;
authProfile: string;
updatedTs: string;
}
export interface SeatSetFields {
seat: string;
rig: string;
runtime: string;
cwd?: string;
authProfile?: string;
}
function isSafeRegistryFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && !st.isSymbolicLink() && st.isFile();
}
/** Reject empty + any control character (covers tab/newline/CR, which would corrupt the TSV). */
function validRegistryField(v: string): boolean {
return v.length > 0 && !hasControlChar(v);
}
function rawRegistryLines(registryPath: string): string[] {
let text: string;
try {
text = fs.readFileSync(registryPath, "utf8");
} catch {
return [];
}
const out: string[] = [];
let first = true;
for (const line of text.split("\n")) {
if (line === "" || line.startsWith("#")) continue;
if (first) {
first = false; // skip header
continue;
}
out.push(line);
}
return out;
}
function rowMalformed(line: string): boolean {
const tabs = (line.match(/\t/g) ?? []).length;
if (tabs !== SEAT_TAB_COUNT) return true;
return line.split("\t")[0] === "";
}
function parseRow(line: string): SeatRow {
// Callers pre-filter via rowMalformed (guaranteed 6 fields); ?? "" satisfies strict indexing.
const f = line.split("\t");
return {
seat: f[0] ?? "",
rig: f[1] ?? "",
runtime: f[2] ?? "",
cwd: f[3] ?? "",
authProfile: f[4] ?? "",
updatedTs: f[5] ?? "",
};
}
export type SeatSetResult =
| { ok: true; seat: string; registryPath: string; mode: string; disclaimer: string }
| { ok: false; reason: "invalid_seat" | "invalid_rig" | "invalid_runtime" | "invalid_cwd" | "invalid_profile" | "unsafe_path" | "io_error" };
/** Atomic upsert of a seat metadata row (tmp + rename). Drops pre-existing malformed rows rather than
* re-emitting fabricated metadata. `now` is injectable for deterministic tests. */
export function authSeatSet(
paths: CodexAuthPaths,
fields: SeatSetFields,
now: () => string = () => new Date().toISOString(),
): SeatSetResult {
if (!validRegistryField(fields.seat)) return { ok: false, reason: "invalid_seat" };
if (!validRegistryField(fields.rig)) return { ok: false, reason: "invalid_rig" };
if (fields.runtime !== "codex") return { ok: false, reason: "invalid_runtime" }; // v0 whitelist
const cwd = fields.cwd && fields.cwd.length > 0 ? fields.cwd : "unknown";
if (!validRegistryField(cwd)) return { ok: false, reason: "invalid_cwd" };
const profile = fields.authProfile && fields.authProfile.length > 0 ? fields.authProfile : "unknown";
if (profile !== "unknown" && !validateProfileName(profile)) return { ok: false, reason: "invalid_profile" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const kept = rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.filter((l) => l.split("\t")[0] !== fields.seat);
const newRow = [fields.seat, fields.rig, "codex", cwd, profile, now()].join("\t");
const body = [SEAT_HEADER, ...kept, newRow].join("\n") + "\n";
const tmp = `${paths.registryPath}.${process.pid}.tmp`;
try {
fs.mkdirSync(path.dirname(paths.registryPath), { recursive: true });
fs.writeFileSync(tmp, body, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, paths.registryPath);
fs.chmodSync(paths.registryPath, 0o600);
} catch {
try {
fs.rmSync(tmp, { force: true });
} catch {
/* ignore */
}
return { ok: false, reason: "io_error" };
}
return { ok: true, seat: fields.seat, registryPath: paths.registryPath, mode: "600", disclaimer: SEAT_REGISTRY_DISCLAIMER };
}
/** List well-formed seat rows (malformed rows skipped). */
export function authSeatsList(paths: CodexAuthPaths): SeatRow[] {
return rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.map(parseRow);
}
export type SeatShowResult = { ok: true; row: SeatRow } | { ok: false; reason: "invalid_seat" | "missing_seat" | "unsafe_path" };
export function authSeatShow(paths: CodexAuthPaths, seat: string): SeatShowResult {
if (!validRegistryField(seat)) return { ok: false, reason: "invalid_seat" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const row = authSeatsList(paths).find((r) => r.seat === seat);
return row ? { ok: true, row } : { ok: false, reason: "missing_seat" };
}
export interface SeatsReport {
registryPresent: boolean;
registryMode: string | null;
registryModeSafe: boolean | "unknown";
total: number;
known: number;
unknown: number;
malformed: number;
}
export function authSeatsReport(paths: CodexAuthPaths): SeatsReport {
const present = isFile(paths.registryPath);
const mode = present ? fileModeOctal(paths.registryPath) : null;
let total = 0;
let known = 0;
let unknown = 0;
let malformed = 0;
for (const line of rawRegistryLines(paths.registryPath)) {
if (rowMalformed(line)) {
malformed += 1;
continue;
}
total += 1;
const profile = line.split("\t")[4];
if (!profile || profile === "unknown") unknown += 1;
else known += 1;
}
return {
registryPresent: present,
registryMode: mode,
registryModeSafe: mode === null ? "unknown" : mode === "600",
total,
known,
unknown,
malformed,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
// release-0.3.2 slice 12 — dot-ID grammar per the scope-and-versioning
// convention, §1.
// release-0.3.2 slice 12 — dot-ID grammar per
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
//
// Grammar (v0 single-project):
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
@@ -62,7 +62,7 @@ export function isPlaceholderOnlyBlock(block: string | null): boolean {
return lines.every((l) => isScaffoldPlaceholderText(l));
}
/** PM dogfood #1 — true when `body` is a
/** PM dogfood #1 (qitem-20260720015700-630eef64) — true when `body` is a
* PRESENT section whose EVERY non-blank line is scaffold-placeholder
* content: either a bare placeholder line, or a structural list row
* (numbered `1.`/`1)`, checkbox `- [ ]`, or bullet `-`/`*`) whose text is a
+1 -6
View File
@@ -166,11 +166,6 @@ export function deriveMissionDependencyGraph(input: MissionDependencyGraphInput)
// without this file changing too).
export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
// A slice directory's name: two or more digits, a dash, then the slug. `rig scope slice create` numbers past 99
// (100-…), so a two-digit-only check flags names OpenRig made. The CLI audit and the daemon's audit route both read
// this one pattern.
export const SLICE_DIRECTORY_NAME = /^\d{2,}-/;
function childPath(parent: string, child: string): string {
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
}
@@ -427,7 +422,7 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult {
});
}
// PM dogfood #1 — per-SECTION source
// PM dogfood #1 (qitem-20260720015700-630eef64) — per-SECTION source
// selection, decided independently for `## Mini-requirements` and
// `## Proof contract`: an authored PRD section is canonical, but a
// PRESENT-and-PRISTINE scaffold-only PRD section yields to an authored
+4 -14
View File
@@ -554,9 +554,8 @@ export function findSlice(
continue;
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
const resolved = path.resolve(candidate);
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
}
}
if (firstContextError) throw firstContextError;
@@ -568,18 +567,9 @@ export function findSlice(
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
// path.dirname never ends in a separator, so compare against the normalized root
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
// slice the same way so a relative root and a relative candidate stay comparable.
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
// to the walk as written, where it reads as before.
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
}
function walkToMission(root: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(root) && dir !== root) {
if (path.dirname(dir) === root) return dir;
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
dir = path.dirname(dir);
}
return null;
Loaded 100 of 861 files, more files were not shown because too many files have changed in this diff. Show more