Compare commits

...
Author SHA1 Message Date
dev-guard 71389bef06 test(daemon): cover complete message ownership at native Codex width 2026-10-28 21:39:36 +00:00
dev-guard ebf20ae616 fix(daemon): refine protected Codex paste admission and recovery 2026-10-28 21:35:18 +00:00
dev-guard 4c14ca76c4 test(daemon): cover native Codex paste limits and fitting envelopes 2026-10-28 21:35:17 +00:00
dev-guard 682bede406 fix(daemon): retain protected Codex messages before an unobservable paste 2026-10-28 21:13:18 +00:00
dev-guard db45c6bcb8 test(daemon): cover Codex paste visibility before input 2026-10-28 21:13:18 +00:00
Mike Schwarz 03a2828ce7 fix(daemon): keep prompt alerts routable with nonlocal senders (#2237)
When a seat sits at a choice prompt and its row came from another host, the alert about it now still reaches someone, and one such row no longer stops the whole pass. The wake ladder used the row's original sender as a local destination for the alert's carrier row; a sender on another host failed local destination validation and threw, aborting the shared prompt pass, and the same choice in the ordinary ladder failed the whole tick. A shared carrier choice now picks an admissible configured operator or the original sender, otherwise the blocked seat itself, at the prompt pass, its consumed-hop continuation, and the ordinary ladder's continuation and operator-floor alert. The carrier is never nudged, the original sender stays as the row's source, and the existing human-alert policy (including explicit-only) is unchanged.

3 files: source (queue-wake-ladder.ts, +13 -6) and tests (+51). No new refusal, error code, timer or setting.
2026-10-28 13:47:57 -07:00
Mike Schwarz c57f0173ab feat(tui): open the terminal scope being viewed (#2232)
The TUI's terminal button now opens the scope you're looking at: an agent's page opens just that agent, a pod's page gets an "Open terminals ▸ pod <pod>" button, and the rig's page opens the rig. An agent row's term link and the attention card's chat line now open that one seat instead of its whole pod. The daemon gains a seat:<rig>/<logical-id> terminal view beside pod:, built from the rig's own seat list; an unknown or malformed seat gets the existing view_not_found error, and rig terminal open's help lists the pod: and seat: forms.

11 files: daemon terminal view (+18 -1) with its test (+21), TUI render, attention and link styling, CLI help, and TUI tests; +112 -24 in total. No new refusal or error code.
2026-10-28 13:37:52 -07:00
Mike Schwarz 72086329ae docs: v0.7.4 install pins, current release-notes steps, and openrig-operator references routed to the help guide (#2238)
The README, getting-started and quickstart now install and add skills from the current release, v0.7.4, instead of v0.7.3. docs/releases/README.md says it holds the per-release notes for v0.1.12 through v0.6.3 plus v0.6.12, points to CHANGELOG.md and GitHub Releases for later ones, and its release steps match how releases are published now. Shipped skills and the kernel culture no longer route to a skill that isn't shipped: rig-lifecycle points to rig context get help, the kernel culture's restart case points to getting-started's "Incomplete setup and restart", and the dead sibling entries are dropped. Both skill copies stay identical and their digests are updated.

12 files, +28 -56: docs, skill text and the digest file only; no code change.
2026-10-28 13:31:55 -07:00
Mike Schwarz 8684c981a6 fix(daemon): retry original queue wakes after prompt clear (#2235)
A queue item created while its recipient sits at a selection prompt now gets its original wake retried once the prompt clears, even after a daemon restart or when an idle hook masked the pane's attention state. A definite no-write prompt refusal arms recovery; the first positive clear can retry the durable failed wake intents, reusing the same row and frozen wake body. Delivered and indeterminate wakes are never re-sent. Each clear pass holds per-row reservations, so overlapping passes can't release each other's rows or re-arm a refusal. For #2219.

3 files: source (queue-repository.ts, startup.ts; +44 -16) and tests (+107 -5). No new refusal, error code, timer or schema change.
2026-10-28 12:57:05 -07:00
Mike Schwarz e5c4cba385 perf(inventory): reuse node identity for activity reads (#2236)
Node inventory now reads each node's activity by the node ID it already has, alongside the current session name, instead of resolving the name back to a node, and attention enrichment no longer repeats a hook read whose result was discarded. A newer same-named session on another node can no longer supply this node's activity or attention state. No-session rows and the standalone attention fallback keep their behavior. In one matched measurement (15 rigs, 75 nodes) the change removed 900 SQL calls per command and cut the CLI median from about 1,028 ms to 622 ms; no general speedup is claimed.

3 files: source +6 -5, tests +71 -1. No cache, schema or API change.
2026-10-28 12:50:17 -07:00
Jay 10b5f155b3 fix(daemon): send wake-ladder retries as OpenRig and pass workflow entry sender (#2229)
Queue wakes from the daemon no longer arrive as "From: <unknown sender>". A wake-ladder retry now names OpenRig (daemon@kernel) as its sender and keeps the row's own source visible in the body ("Row source: <seat>."), and a workflow's first entry wake names the session that started the workflow. Retry timing, caps and admission are unchanged. For #2223.

4 files: source (queue-wake-ladder.ts, workflow-runtime.ts; +7 -2) and tests (+54 -3). No new refusal.
2026-10-28 12:27:11 -07:00
Brad Vincent 5700c6c2ff fix(daemon): preserve OS argv boundaries in Codex identity checks (#2212)
Codex resume identity stays correct when a supported profile or working directory contains spaces. The daemon now reads each foreground Codex process's exact arguments from the OS (Linux's existing /proc sample, bounded macOS KERN_PROCARGS2) instead of splitting flattened ps text, so a valid managed resume isn't rejected and a UUID inside an option value isn't taken for the resumed conversation. Resume and fork proof, restart in place, permission recovery, effort and claim still require exact arguments, and report unknown or unverified when they can't be read. Ordinary sends, queue nudges and watchdog wakes still reach a stable foreground Codex whose arguments can't be read, without treating it as identity proof.

35 files: 9 daemon source files (+286 -52) and 26 tests and helpers (+639 -147). No new public refusal code, migration, dependency or public API change. The macOS argument helper runs once per foreground sample, uncached.
2026-10-28 12:06:34 -07:00
Mike Schwarz d44cf3199a fix(daemon): a restore keeps the model saved with rig seat set-model (#2228)
A seat whose model was changed with `rig seat set-model` keeps that model when it's launched from a snapshot restore: restore now reads the seat's one saved model, as the live profile already does, for both the resume and the fresh restore paths, instead of the snapshot's older model. A seat with no saved model still gets the snapshot's. For #2224.

2 files: source and tests (restore-orchestrator.ts and its end-to-end test).
2026-10-28 11:47:58 -07:00
Mike Schwarz c2378fa315 fix(daemon): rig expand keeps a member's explicit effort (#2227)
`rig expand` with an explicit effort for a new member now keeps it, instead of silently launching at the default: the expansion route and the expansion spec both carry the member's effort as written, and the canonical RigSpec schema still trims a text effort and ignores any other value with an advisory, as before. No new refusal.

4 files: source +5 -0, tests +58 -0.
2026-10-28 11:38:05 -07:00
Mike SchwarzandRudy Celekli 884a5bfb6b fix(cli): keep chatroom watch --tmux rig names literal (#2218)
`rig chatroom watch --tmux` works for any rig name: the watcher goes to tmux as separate arguments after `--`, with no shell; a final `;` is escaped so tmux doesn't split the command; and the session name is encoded for tmux's format. The command prints the session name tmux reports, since tmux versions differ in how they rewrite characters such as `:` and `.` in a name. Finishes #1415 by @rudycelekli.

2 files: source +23 -4, tests +85 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 11:11:11 -07:00
Mike Schwarz 9ba9abe858 perf(inventory): scope startup proof reads to rig nodes (#2226)
Per-rig node inventory now reads startup-proof history only for that rig's current nodes, scoped by node identity rather than the events' own rig id, since a node's history can predate its current rig. The per-node fold and the inventory it produces are unchanged, and other callers keep reading all history. In one matched private measurement (75 nodes, 30,000 proof events, 15 rigs) proof rows read fell from 480,000 to 60,000 and the CLI median from about 1,973 ms to 1,135 ms; a single-rig control was slower, so no general speedup is claimed.

3 files: source +10 -5, tests +35 -5.
2026-10-28 10:58:27 -07:00
Mike Schwarz 2648083e96 perf(inventory): skip discarded rig graph hydration (#2225)
The nodes inventory route checks that the rig exists with one bound query instead of loading the rig's whole graph and discarding it. A missing rig still returns the same 404, and the inventory and its enrichment are unchanged. In one matched private measurement the route's SQL statements fell from 478 to 401 (1 rig) and from 688 to 583 (15 rigs); no general speedup is claimed.

1 file: +2 -1.
2026-10-28 10:51:13 -07:00
Mike Schwarzandakushonkamen 76c0170ec8 fix(daemon): let git over SSH use the daemon's SSH agent in managed seats (#2222)
Git over SSH inside a managed seat can now reach the daemon's ssh-agent for a passphrase-protected key: the daemon forwards its own SSH_AUTH_SOCK through the shared launch environment to every managed launch, and Pi and OMP pass it to the agent process. An empty daemon value counts as unset, so a socket only the seat's terminal has stays out. rig-spec.md says the socket is forwarded and how to check it with ssh-add -l. Finishes #1162 by @akushonkamen, for #1159.

5 files: source +7 -1, tests +18 -10, docs +3 -3.

Co-authored-by: akushonkamen <[email protected]>
2026-10-28 10:33:20 -07:00
Devulapalli Naga Sri Vaishnavi 0224e7710d fix(context): ignore HTML comment blocks in section addresses (#2211)
Context section addresses now ignore headings and code fences inside HTML comment blocks, following CommonMark's comment blocks. Commenting out an old section no longer truncates the section before it, creates a duplicate address, or hides every later section. None of the 245 commented markdown files in our own packs changes.

3 files: source and tests +61 -1.
2026-10-28 10:29:23 -07:00
Mike SchwarzandRudy Celekli 8efb5a59c1 fix(scope): find a slice's mission when the missions root ends in a slash (#2221)
A slice's mission is now found when the missions root is given with a trailing slash or as a relative path: the slice path is resolved once against the root, and a path outside the root falls back to the main walk as before. Nothing else in scope resolution changes. Finishes #1468 by @rudycelekli.

2 files: source +14 -4, tests +37 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:28:29 -07:00
Nash Aschenbath 36f971fa2c fix(daemon): clear Claude permission marks after tool batches (#2209)
A Claude permission notification whose dialog was never shown no longer leaves the seat marked as needing input until the next `Stop` or `UserPromptSubmit`. The openrig-core plugin now also relays `PostToolBatch`, and a batch settles only its own actor's strictly older permission marks once every tool in it has run, so a real pending prompt from another actor or a later batch is untouched. A Claude Code without `PostToolBatch` behaves as before.

11 files: source +144 -29, tests +431 -2, docs +21 -1.
2026-10-28 10:15:58 -07:00
Dery Ferdika b51500f024 test(scenarios): reject unbound step verbs at schema load (#2217)
A scenario that uses a step verb with no runtime binding (`restore`, `mutate` or `policy`) is now rejected when the schema loads, with `UNBOUND_STEP_VERB` guidance, instead of running its earlier steps (which can launch real seats) and then failing partway. `emit` stays separately gated and `seed_regression` stays accepted; the runtime check remains as a floor. Test system only. Three parked library scenarios now refuse at load; none is selected by CI.

4 files: test helper and test +37 -9 approx, docs; +62 -9 total.
2026-10-28 10:13:02 -07:00
Mike SchwarzandRudy Celekli 4926be0c0c fix(cli): treat a trailing slash in OPENRIG_URL as the same daemon (#2220)
`OPENRIG_URL=http://host:7433/` and `http://host:7433` now reach and identify the same daemon. The CLI reads the URL once through one shared reader that trims a trailing slash, so client requests, `rig send`, crash-cart, startup proof and the daemon-lifecycle identity checks all agree. Removing a trailing slash never changes a URL's origin, so the stop identity check still can't match a different daemon. Finishes #1462 by @rudycelekli.

8 files: source +62 -18, tests +169 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:07:54 -07:00
Mike Schwarz 413504a029 fix(host): rig host doctor names the entry replies actually reach (#2214)
`rig host doctor` now checks where a reply addressed to a machine's self-id actually goes, using the same lookup `rig send` uses (the registry id, then a declared `hostId`, then the first matching `hosts.yaml` entry), instead of assuming it reaches the entry being checked. A stale alias that wins over a working entry now shows up in the doctor's output. Routing is unchanged, and the new rows don't change the doctor's exit codes: the failing row appears only when the transport has already failed.

3 files: source +68 -14, tests +94 -0.
2026-10-28 09:42:58 -07:00
Dery Ferdika a70bf71d5f fix(daemon): bound the Slack queue-access active-row scan (#2183)
Slack alert selection now reads the latest owner-notification episode for each active row in one query, and leaves out episodes already posted before it loads full queue rows, instead of two lookups per active row. Bounded by eligibility, not by count: there is no row cap, so no pending ask is dropped, and queue creation order is unchanged. Open asks keep their posted episodes. A failed transition or receipt read now propagates instead of reading as an empty result, and the backlog seeding when a channel is enabled sees the same episodes. No live Slack or large-backlog timing result is claimed.

4 files: source +64 -15, tests +153 -3.
2026-10-28 09:41:36 -07:00
Mike Schwarz 3f17e625af fix: preserve configured Herdr session endpoints (#2205)
`rig terminal open ... --session <name>` now reaches the Herdr session the daemon is configured for, and never silently another one. A name that matches the configured session keeps its explicit socket; other names resolve separately with no fallback; Herdr's config home and session paths come from the daemon, not the caller. The resolved endpoint is carried through the CLI's follow and ensure paths too, which earlier stopped at the daemon's RPC. The Linux-only wiring test doesn't run in the macOS daemon CI job.

9 files: source +24 -19, tests +174 -10, docs +19 -4.
2026-10-28 09:39:10 -07:00
Mike Schwarz 470f55241e docs(cli): explain deferred escalation targets in edge help (#2204)
`rig edge add --help` no longer says both endpoints must be existing logical IDs. It says only `escalates_to` accepts a same-daemon canonical address that resolves when used, with `review-advisor@coord` as an example, and that cross-host and synthetic targets stay unsupported. Help text only; validation and routing are unchanged. Refs #1772.

1 file: +8 -4.
2026-10-28 09:35:54 -07:00
Mike Schwarz 3874c743df perf(test): exec tmux directly in scenario fixtures (#2216)
Scenario test fixtures no longer start a Node process for every tmux command. The fixture's tmux wrapper is now a POSIX shell script that keeps the private socket, the saved PATH and literal arguments, and execs tmux, so its exit codes and signals pass straight through. A new forwarding test covers both an exit and a signal with no Node on the saved PATH. Test helpers only; no product code changes, and no production speedup is claimed.

2 files: test helper +6 -11, tests +33 -1.
2026-10-28 09:33:55 -07:00
Mike Schwarz b0bc678833 fix(test): honor send verification in scenario adapter (#2215)
A scenario action that sets `send.verify: true` now runs `rig send --verify`, so scenario tests check what they say they check. Before, the adapter dropped the flag and ran an unverified send. False or absent values keep the existing arguments. Test helpers only; no product code changes.

2 files: test helper +2 -2, tests +5 -3.
2026-10-28 09:29:36 -07:00
Mike Schwarz 26a490938c fix(daemon): route a terminal seat's stuck-sweep finding to a person (#2192)
When work owned by a bound terminal seat (`runtime: terminal`, such as a plain shell) is stuck, the stuck sweep's finding now goes to someone who can act on it instead of becoming the terminal seat's own row. It joins the fallback the sweep already uses for an address with no wake route and for an unclaimed hosted member: the operator seat when it's a person, then the row's creator, then the operator agent, and a terminal seat is never chosen. When none of them can be reached, the finding stays with the terminal seat and says why. The typing guard is unchanged. This covers the stuck sweep only; parked-owner watchdog reminders are separate.

2 files: source +11 -5, tests +34 -0.
2026-10-28 09:27:37 -07:00
Mike Schwarz afee489108 fix(provider): retain the launcher and bound target during relaunch (#2196)
A same-conversation account switch no longer leaves a Claude seat stopped when its running executable is outside the daemon's PATH. Before the takeover ends the old process, in-place restore reads the executable the seat is actually running, through the existing native witness, and carries it through the classic and managed resume builders; when it can't be read, launch resolution stays as it is today. The takeover also keeps the selected node identity through the guarded TERM, KILL and respawn instead of resolving a bare pane id again, so it can't act on another seat that holds the same pane id. The guard still checks the bound session, pane and occupant before writing. Config-dir selection and the resume token step are unchanged.

13 files: source +58 -25, tests +54 -6.
2026-10-28 09:16:42 -07:00
Mike Schwarz 898b39ea5c fix(cli): verify cross-host queue receipts on the destination (#2193)
A cross-host `rig queue create --verify` now reads its delivery receipt from the destination daemon, so a successful remote create no longer reports a 404 and an indeterminate result. Verification reuses the destination reader that create reconciliation already selects, including the registered HTTP endpoint and its bearer, and a host naming the current daemon stays local. The receipt must match the created item's ID, destination, sender and body, so an older row reached by a reused ID can't read as verified. An unreadable or mismatched receipt stays indeterminate, and neither the create nor the wake is resent.

2 files: source +38 -11, tests +90 -0.
2026-10-28 09:13:13 -07:00
Mike Schwarz b71a9288c0 fix: recognize two-row Codex footers in viewer input checks (#2206)
In a task-following view, a departed Codex agent's tile now closes once its composer is confirmed empty. Codex 0.164 draws a model and path status row and then a shortcuts row; the capture reader already recognized that two-row footer, but the cursor-bound reader follow uses accepted only one row, so every Codex tile read as unconfirmed input and stayed. The two readers now share the existing footer boundary. Typed drafts, literal placeholders, moved cursors, menus, incomplete frames and ambiguous captures still keep the tile, and only the viewer closes, never the agent. The same reader feeds delivery's draft-aware and openrig-urgent typing guards, so a truly empty Codex 0.164 composer there can now receive typed delivery.

4 files: source +22 -12, tests +94 -2.
2026-10-28 08:46:38 -07:00
Mike Schwarz 6957751f5e feat(terminal): follow task membership in one workspace (#2199)
`rig terminal open qitem:<id> --follow` keeps one Herdr workspace following a task: an agent that takes an open obligation in the task joins the view without taking focus, and one whose work there ends loses only its viewer tile after a grace period, with the agent and its conversation left running. An explanation strip says why each tile joined or left. A focused tile is kept briefly for reading, typed or unreadable input keeps its tile, and pins, temporary suppression and an eight-tile limit can be changed on purpose. A failed or incomplete read keeps the previous view; a task with no open obligation retires its follower and keeps the workspace. Following wakes on queue, session and node changes and one-shot deadlines, not by polling.

Qualified on Linux with Herdr 0.9.5 (protocol 22). Other platforms and other view kinds keep ordinary one-shot open. Also fixes the viewer inventory call that Herdr 0.9.5 rejected (`workspace list --json`).

21 files, +1265 -44.
2026-10-27 14:13:32 -07:00
Mike Schwarz ae59c4287a fix(cli): wait for slow send results before reporting uncertainty (#2198)
`rig send` now waits up to 60 seconds for its result, on local and HTTP sends, plus any `--wait-for-idle` time. A send that answers after five seconds reports its real outcome instead of "Delivery UNCONFIRMED", which led agents to send the same message again. When there is still no answer, the guidance says a busy agent can hold delivered input off-screen, so an empty pane capture does not prove the message was lost: check with the recipient before sending again or filing the same work as a queue item. Nothing is retried, and no refusal is added.

Refs #2195. The send path's own latency on the reporter's machine is not changed here.

3 files, +57 -29 (source +17 -21, tests +40 -8).
2026-10-27 13:29:33 -07:00
Mike Schwarz 0c44b23ae9 fix(scope): accept slice folders numbered 100 and above in the audit route (#2194)
OpenRig's own `rig scope slice create` and `slice move` number slices past 99, and the CLI audit has accepted those folders since 1016, but the daemon's scope audit route, which the web UI's Scope pages read, still required two digits and reported each `100-…` folder as a high naming violation. Both audits now use one shared pattern. The conventions doc also says what a slice move does to proof: a judgment binds the slice's path, the item's text and the policy, so moving or renaming a slice reopens its judged items, `rig proof show` lists them, and the scope audit checks structure, not readiness.

6 files, +47 −4 (source +15 −4, tests +26, docs +6). No new refusals.
2026-10-27 11:49:36 -07:00
Mike Schwarz e5f8a8eee7 docs(roadmap): mark 0.7.4 released (#2197)
The roadmap now lists 0.7.4 as released, with a link to its release and its published title, and points readers to the release notes for what shipped, the experimental and security notes for hosted members, upgrade instructions, credits and the remaining testing gaps. It replaces the 0.7.3 section; "How to help", "Testing" and the backlog sections are unchanged. Text only.

1 file, +4 -10.
2026-10-27 11:49:06 -07:00
Mike Schwarz 84d6bbdbd9 docs(presets): say what the pi-tui example was checked to do, and its observed limits (#2184)
The Pi terminal UI example said that whether Pi takes a wake and acts on it hadn't been checked. One native check has now run, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in 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, so this shows the wake reaching Pi and Pi acting on it, not Pi choosing to park by itself. The same check found two unsafe cases, now stated as observed: with the default typing guard a wake submits a person's half-typed draft together with the wake, and a send while a Pi dialog such as `/settings` is open types into the dialog. The docs say to run the seat unattended, not where a person also types, and promise no remedy. How Pi takes a wake while it's busy, resume and other Pi versions stay unshown. The launcher is unchanged.

2 files, +14 −8. Text only.
2026-10-27 10:12:52 -07:00
Mike Schwarz 4f1d031bab docs: mark hosted members experimental, with how to keep one safe (#2189)
The hosted-members reference described how to serve a hosted member over HTTP behind a tunnel or proxy, with no warning about what that exposes. It now opens with why the feature exists (assistants in a vendor's cloud can only reach a server on the internet) and a note: it's experimental, unsupported and off by default; nothing in OpenRig listens on the internet unless you set it up; serving `rig mcp serve --http` behind your own TLS tunnel or proxy with a token is like opening a port in your firewall, and 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 with `rig mcp token revoke <name>`; OpenRig reachable from the internet isn't supported.

1 file, +4. Text only.
2026-10-27 10:11:14 -07:00
Wu Shuwen 81a48ce6c6 fix(ui): show the selected rig configuration in overview (#2188)
A running rig's Overview matched a Specs-library template by name, so a Claude-only kernel could appear to use the dual-runtime template, and a project rig outside the library said "NO RIG SPEC". The Overview now reads the selected local rig's existing `/api/rigs/:id/spec` export and labels it current configuration, not a library template. The existing review renderer supplies topology, configuration and YAML; if preview preparation fails, the actual export stays visible. A read failure no longer substitutes a same-named template, and a remote or undetermined host reads nothing local. The Specs library continues to show reusable templates, and nothing changes in the daemon.

3 files, +192 −53 (source +54 −53, tests +138). No new refusals.
2026-10-27 10:07:10 -07:00
Mike Schwarz c823dfedb4 fix: tell a hosted member how it gets work, what it may read and how to close (#2187)
Three things a hosted member is told were misleading in a native test, and the member repeated or acted on each. A hosted member with no wake trigger fell through to the general unroutable teaching, "Fix the address, or register the human with `rig gateway human add`". It now reads that the member is a hosted member with no wake trigger, so nothing can push work to it, and that it reads the queue when its owner next starts a session. The result is still `unroutable` with the same prefix, so the stuck sweep still tells the member's person, and an address no roster lists keeps the general teaching. The member workflow, now `member-workflow/v2` and quoted in `docs/reference/hosted-members.md`, says to describe reach from the grant rather than the tool list, and to close a finished item with `no-follow-on`. `rig_queue_update`'s closure-reason description lists the reasons it takes. No new reason is accepted, and nothing else changes.

7 files, +74 −9 (source +40 −5, tests +33 −3, docs +1 −1). No new refusals.
2026-10-27 10:00:10 -07:00
Mike Schwarz 30a3628b7d docs(presets): say a preset's first input carries only a send_text startup file (#2185)
The terminal-Pi recipe said a community preset with `first_input: true` carries the startup pointer in the seat's first startup text file. That's true only for a file declared with `delivery_hint: send_text`: the first such file is folded into the first input after the identity context. A `.md` file left at the `auto` hint resolves to guidance, which a community seat doesn't take, so a person who wrote the pointer that way got nothing but a launch warning. The bullet now says so.

1 file, +3 −2. Text only.
2026-10-27 09:38:52 -07:00
Mike Schwarz 4ee8bdca0c docs(presets): add a pi-tui example that runs Pi's terminal UI as a community harness (#2182)
A plain `runtime: terminal` seat running interactive Pi gets no queue wakes. A community-harness seat is an agent seat to OpenRig, so its wakes are delivered to the pane. This adds a copyable example that runs Pi's terminal UI that way: `docs/reference/community-harness-examples/pi-tui/`, with a `pi-tui` preset (`names_conversation` and `first_input`, no `resume`) and a POSIX sh launcher, a section in `community-harness-presets.md`, and stand-in tests against a fake `pi`.

The launcher, written for Pi 1.4.0 from its source, gives each seat its own Pi agent folder and session folder (a leading `~` in `PI_CODING_AGENT_DIR` is expanded as Pi does), copies the installed `openrig-core` skills it's given at each launch, and starts Pi with `--session-dir`, the session id OpenRig named and the first input passed byte for byte after `--`. On resume it opens the one session file whose first line, read as JSON, has the top-level `type` `session` and that `id`, and starts nothing otherwise, because Pi's own `--session-id` and `--session` can create or pick another session. Whether Pi takes a wake and acts on it, resume, and draft safety haven't been checked with a real Pi; the preset and docs say so. No core code, delivery or terminal behaviour changes.

4 files, +260 −1 (launcher +100, preset +14, docs +24 −1, tests +122). No new refusals.
2026-10-27 08:25:07 -07:00
Nash Aschenbath a50e56e868 fix(cli): show feed subscription hints after host add and pair (#2179)
Registering a remote host makes it visible in topology, but its attention items reach FEED only after `feed.subscriptions.<id>.enabled` is set, and nothing told the person so. After `rig host add` or `rig host pair`, the human-readable next steps now include, after `rig host doctor`, the local command `rig config set feed.subscriptions.<id>.enabled true`, using the registry id as given, dots included. An SSH entry is told that remote FEED needs an HTTP entry and how to pair one. An id that feed settings reserve, such as `progress`, gets a different-id suggestion instead of a command that wouldn't work. Registration still leaves the setting alone, and `--json` still returns its one result. The CLI reference describes the behaviour, and the tests run the printed command through the real `config` command.

4 files, +84 −7 (source +18 −2, tests +49 −5, docs +17). No new refusals.
2026-10-27 07:38:29 -07:00
Mike Schwarz ef5117441f docs: say what a terminal seat's typing guard set to off types into a shell (#2181)
A terminal member defaults to the `sends-only` typing guard, which holds OpenRig's own notices such as park reminders and watchdog messages. The reference didn't say what happens if someone sets a terminal member to `off`: those notices are typed into whatever the pane is running, and a shell prompt runs each line as a command, From and To lines included, because 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. The paragraph says this below the modes table and advises keeping the default for a seat that may fall back to a shell.

1 file, +6. Text only.
2026-10-27 07:23:57 -07:00
Mike Schwarz a17fdc5faa docs(presets): say a queue wake doesn't reach Pi in a plain terminal seat (#2180)
The terminal-Pi recipe tells Pi to park a CI wait with `--wake-after`, which reads as if the wake will bring Pi back. In a plain `runtime: terminal` seat it doesn't: a parked row's wake isn't typed into Pi, and a handoff to that seat records a failed wake (`failed:terminal-seat`). A native check with Pi 1.4.0 in a real terminal seat showed both. The recipe now says so after its startup pointer, and that the person checks the seat's parked rows (`rig queue whoami --session <seat>` or `rig queue show <id>`) or tells Pi to continue the named row. It makes no claim about community-preset seats and promises no fix.

1 file, +5. Text only.
2026-10-27 07:07:48 -07:00
Mike Schwarz 9110ab9dc9 fix(cli): attach --self reads its tmux pane even when tmux rewrites separators (#2178)
After `rig attach --self` inside tmux, the session could send with its team address, but a reply to it failed with `transport_unavailable`. The command asked tmux for the session, window and pane joined by newlines and split on them, but tmux writes control characters in that output as `_`, so it returned `name_@2_%2`, the split found no pane, and the attachment silently fell back to the external CLI. It now asks for `#{pane_id} #{window_id} #{session_name}` separated by spaces and reads the two ids by their fixed shapes, leaving the session name as the rest of the line, so names with spaces, underscores or id-like words resolve too. The pane argument is shell-quoted. Outside tmux, a tmux error or unexpected output still gives the external CLI attachment. The tests now render the format the way tmux does, including a real tmux server, and the end-to-end check delivers a reply to the original pane.

3 files, +78 −19 (source +10 −9, tests +68 −10). No new refusals.
2026-10-27 06:12:40 -07:00
Mike Schwarz a6f61d9a0a fix(daemon): re-send a wake recorded unroutable once the seat's binding exists (#2170)
A row created before its seat's pane was bound records its wake as unroutable, and the stuck sweep then reported an undelivered wake that nothing retried: the seat spent a turn on a false finding, and the row's own wake was never sent. Now, once that destination has a terminal binding, the sweep delivers the row's own durable wake intent instead of reporting it: it claims the intent, sends, and settles it delivered together with the row's result, so the outbox drain doesn't send the wake again, and it sends nothing when the intent is already sending or delivered. A failed send is still reported on the next tick, a row whose result didn't change is reported as before, the retry ladder and quiet hours are untouched, and an address that still has no binding is reported as before.

2 files, +79 (source +10, tests +69). No new refusals.
2026-10-27 04:21:31 -07:00
Dery Ferdika bd1158660a fix(cli): send normalized line counts for transcript --tail and capture --lines (#2164)
rig transcript --tail and rig capture --lines now send the same line count on every path. A count of 0, a negative number or a non-numeric value falls back to the documented default of 20 instead of reaching the daemon as zero, so rig capture --lines 0 shows the default view (20 history lines plus the visible pane) rather than the visible pane alone, and rig transcript --tail 0 returns the default 50 lines. A positive count is unchanged. Over SSH, a value parseInt reads as a positive count is forwarded as typed, so the remote CLI applies the same rule, and the local, cross-host HTTP and SSH paths agree.

6 files, +70 -4 (source +13 -4, tests +57). No new refusals.
2026-10-27 04:12:02 -07:00
Mike Schwarz b9dd23db87 docs(bundles): say at submission that an exported team needs a declared README (#2176)
Someone submitting a team to openrig.dev/rigs now learns before they submit that the registry's check runs rig bundle check on their folder at the submitted ref with the latest published OpenRig, and that any finding fails it. An exported team has no README of its own, so the publishing guide's submit section and the rig-bundles-and-shareable-artifacts skill now say to add one, declare it in rig.yaml docs, and run rig bundle check at the commit being submitted. The skill gains a short section from an exported team to a submission; its mirror and edge digests are regenerated.

4 files, +29 -2 (docs and skill text only). No behaviour, flag or check changes.
2026-10-27 03:59:27 -07:00
Mike Schwarz b8fe8f1e11 fix(terminal): report unconfirmed panes without counting opens (#2173)
rig terminal open no longer counts a seat as opened when Herdr can't confirm its pane. When Herdr accepts a layout without a tab ID, or its pane list can't confirm a requested seat, that seat is reported as degraded with code pane_unconfirmed and a note to inspect before retrying. Confirmed seats still count, and a partly confirmed open still succeeds; an open with no seat confirmed returns ok false and a nonzero CLI exit. The CLI, the TUI's notices and the desktop failure path name unconfirmed panes separately from skipped seats, and unconfirmed panes get no restart-command report.

Labels are matched honestly. A listed pane counts for one seat at most. When a listed label could belong to more than one seat label (an exact label that is also a prefix of a longer one, or a truncation), those seats are confirmed only when the exact listed labels account for every candidate's seats; otherwise they read unconfirmed, and an undecidable pane never makes a seat read exited. Healthy paging, focus, starting-tab protection and workspace reuse are unchanged. Healthy provider fixtures now return real tab IDs and pane inventories.

15 files, +331 -84 (source +48 -28, tests +270 -56, docs +13). No new refusals; the new structured value is degraded[].code "pane_unconfirmed".
2026-10-27 03:50:34 -07:00
Mike Schwarz 588ba54d9d docs: carry 0.7.4's release notes and capability map to main (#2175)
Main's documentation catches up with the accepted 0.7.4 candidate's release text, and its install pins point at the latest published release. The CHANGELOG gains the 0.7.4 section and the capability map its 0.7.4 teaching, both identical to the candidate's; getting-started's reboot troubleshooting row now says a kernel that was already down before the reboot stays down and names rig up kernel --existing, while main's newer rig start --last text stays. README, getting-started and quickstart install pins move from v0.7.0 to v0.7.3, the latest published release; the move to v0.7.4 follows at publication. whats-new stays at main's 0.7.1 note, because the packaged note must name the CLI package's version, and package versions, the lockfile and the packaged-plugin fixture are unchanged.

5 files, +383 -14 (docs only). No behaviour, flag or check changes.
2026-10-27 03:41:46 -07:00
Mike Schwarz d87e843fd2 docs(harness): give an interactive Pi in a terminal seat the coordination skills (#2174)
Someone running interactive Pi from a terminal seat, or from a community preset's launcher, gets a written recipe for giving that agent OpenRig's coordination skills, which OpenRig doesn't install for those seats. The new section in the community harness presets guide copies whole skill directories from the installed openrig-core plugin into the seat's Pi agent directory, and puts one startup line in the seat's first turn that names the queue-handoff skill and carries the CI-wait instruction (rig queue block with an external blocker and a wake). Managed Pi seats and Workshop's pinned copies stay on their own routes, and the section says a copied folder and a startup line don't show that Pi discovered or follows the skill.

1 file, +43 (docs only). No behaviour, flag or check changes.
2026-10-27 03:21:35 -07:00
NIKHIL a04bc1cf2f fix(daemon): release unread notification response bodies (#2172)
The ntfy and webhook notification adapters now cancel each response body they don't read, right after reading its status, so a slow or streaming endpoint no longer holds a connection open until garbage collection. send() still decides success or failure from the status alone and doesn't wait for the cleanup; a cleanup error is ignored and can't change the result. The new tests use a loopback server that holds the body open and assert that the server sees the response close, plus a rejecting cleanup that leaves the result unchanged, for both adapters and both a 200 and a 503.

3 files, +50 (source +4, tests +46). No new refusals.
2026-10-27 03:10:53 -07:00
Nash Aschenbath be52fa0da2 test(daemon): share the compaction submission fixture clock (#2169)
The compaction receipt tests no longer fail when a scheduler pause uses up the fixture's 20 ms idle budget. The enforcer and the transport's injected clock read the fixture's stepped clock while the transport's idle deadline read the real Date.now and slept on real timers, so a pause could turn an idle first submission into not_sent before a cancellation assertion expected submitted_unconfirmed. The fixture now shares one clock for Date.now, the injected now() and the transport's sleeps, as production does with real time; the receipt helpers restore only their send spy, and afterEach restores all mocks. The test-layers guide documents the clock boundary. Production code, the 20 ms budget and the assertions are unchanged; real-timer deadline coverage stays in the transport suite.

2 files, +15 -9 (tests +10 -9, docs +5). No production change.
2026-10-27 02:40:44 -07:00
Nash Aschenbath bf893d3f4d fix(tui): show the configured local host name (#2162)
After rig host rename, the TUI's Topology explorer and host pages show the configured name beside the host's identity, for example "Mac mini 2 (mac)". The TUI reads the name the daemon already serves and keeps it separate from the identity used by commands, navigation, row selection and local reads, so a rename stays stable even when the name matches a registered remote host's id. Control and bidirectional-text characters are left out of the label, and the name is cleaned before the default check: the daemon's default name, an absent, blank or matching name, or one that becomes the default after cleaning, shows the identity once. Addresses the remaining local-name item in #2049, following #2052.

8 files, +154 -15 (source +58 -10, tests +85 -5, docs +11). No new refusals.
2026-10-27 02:39:16 -07:00
Mike Schwarz a3a48e171b fix(daemon): rig down keeps a rolled-back rig down; say which rigs rig start --last brings back (#2166)
An explicit rig down now records the stop for a rig whose latest seat sessions are blank rollback sessions left by a failed start, once their tmux targets are confirmed absent, so rig start --last keeps that rig down. Before, rig down marked only exited or detached sessions, and a rolled-back start left the rig eligible for revival. Genuinely live or racing sessions are never marked, and crash recovery for rigs that weren't explicitly stopped is unchanged. The node inventory reports such a recorded stop as stopped on purpose.

rig start --help and getting-started now say which rigs --last brings back: a rig comes back 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, and running rig down <rig> once (by id when names are shared) keeps it down. --last never selects the kernel; under --no-kernel its automatic restore is skipped too, and rig up kernel --existing brings it back.

6 files, +83 -7 (source +23 -7, tests +50, docs +10). No new refusals.
2026-10-27 02:36:01 -07:00
Mike Schwarz de6b635c52 fix(daemon): rig ps reports periodic snapshots only for rigs the scheduler snapshots (#2168)
rig ps (through --full and the API) now reports periodicSnapshotActive as true only for rigs the periodic snapshot scheduler actually snapshots: not archived, with at least one node whose newest session is running. Stopped, zero-seat and archived rigs read false. The scheduler's running-rig query is exported and shared, so the flag and the scheduler can't disagree; the scheduler's own selection is unchanged. Following Christian Todie's change in cerebral-work/openrig PR57.

3 files, +80 -18. No new refusals.
2026-10-27 02:03:13 -07:00
Mike Schwarz 0e8bdd4488 docs(skills): park a row that waits on CI or a long job (#2167)
The queue-handoff skill now says how to wait on something outside the queue, such as CI or a long job: park the row with rig queue block <id> --on external:<what> --wake-after <duration> --continuation "<what resumes>". The row stays yours, reads parked rather than stalled, and the timer wakes you. Text only, with the skill digest regenerated.

2 files, +6 -2.
2026-10-27 01:56:58 -07:00
Dery Ferdika db26369483 docs(test-layers): correct CI scenario counts and case list (#2165)
docs/as-built/test-layers.md now matches scripts/run-pr-scenarios.sh and the CI README: the installed-scenario job starts fifteen fresh containers over nine case options (two baton cases three times each, the refocus-parked effect case three times, and six passing-only cases once each), and the --case and --mode options list them all. Text only.

1 file, +14 -9.
2026-10-27 01:54:57 -07:00
Mike Schwarz 80616b7926 feat(terminal): ensure verified task viewers without relayout (#2133)
A task preview can report a verified provider terminal mapping, and an owner can ensure a missing viewer through POST /api/terminal/viewer/ensure without opening another whole-team layout. The operation re-derives current membership and the preview precondition, serializes creation per endpoint, reuses the returned pane and terminal pair, and records presentation provenance before any effect; a lost reply is inspected before any repeat, and an uncertain or changed identity stays unconfirmed. It doesn't relayout, close departed viewers, take over control, or start or stop agents. Writable shared tile and recovery attaches add tmux's ignore-size flag (read-only attaches keep -r), and CLI window reuse recognizes both writable attach spellings.

The existing open path keeps its current results and accounting: a stricter accounting of id-less or unconfirmed opens is deferred to its own change, with its consumers updated together. Strict verification applies to the new ensure and lookup paths.

19 files, +651 -27 (source +296 -9, tests +321, docs +34). No new refusals.
2026-10-27 01:51:44 -07:00
Nash Aschenbath 2fbe0ddb49 fix(daemon): require source evidence to resolve stuck findings (#2153)
An existing stuck-sweep finding now closes only on the source's delivery, pickup or explicit custody evidence. A failed wake becoming unconfirmed, a fresh human-notification post window, an unreadable registry or a larger reporting-age threshold no longer closes a finding just by removing it from a report. Claimed items returned to pending take part in the existing stalled-pickup derivation, and human notifications stay separate from agent wakes.

Older human notices without owner-notification history can close on their item-scoped posted receipt when the recipient is known to be human and unchanged: a destination reroute, or a park whose person changed, needs a new matching notification episode, since the old receipt carries no recipient key. Closure notes name the evidence and record stuck-sweep@system as the actor, keeping finding ownership. Refs #1419, following #1449 and #1811; addresses the false findings reported in #2151.

5 files, +515 -19 (source +91 -8, tests +376, docs +48). No new refusals.
2026-10-27 01:08:45 -07:00
Nash Aschenbath aaa681c28a fix(daemon): require current launch evidence for fresh handovers (#2159)
A fresh in-place Claude handover now sends its context packet only after it sees Claude's own drawn input frame on the current screen and the matching launch process, stable across the existing process observations and pane checks. Retained scrollback that looks ready, or a shell prompt line that starts with the same prompt glyph, no longer counts. If that proof doesn't arrive within the existing readiness timeout, the handover stops with a clear message and commits nothing.

Ordinary fresh starts, adopted seats, resume and fork keep their readiness paths, and visible gates and identity conflicts keep their specific results. Refs #1544, following the current-screen qualification in #1553.

6 files, +237 -54 (source +43 -19, tests +157, docs +37). No new refusals.
2026-10-27 01:00:50 -07:00
Mike SchwarzandRudy Celekli 9ca37f1ede fix(cli): warn on out-of-range MCP daemon ports while serving (#2160)
rig mcp serve with a --port outside 1-65535 (including 0) now prints one warning to stderr and still starts, so an MCP host can connect and list tools; only the daemon calls fail, as before. Nothing goes to stdout, the MCP protocol channel, and no other port is substituted. A non-numeric --port is still refused, and an empty --port keeps using the daemon's own port. This finishes #1416.

3 files, +102 -1 (source +4 -1, tests +98). No new refusals.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-27 00:22:51 -07:00
Mike Schwarz a5e42d3d6b fix(daemon): return 409 for runtime send refusals and name the stale-pane repair (#2157)
A send refused because of the target seat's runtime state (target_runtime_conflict, target_runtime_not_running, target_runtime_unverified) now returns 409, like the other seat-state refusals, instead of 500. When the refusal is a stale bound pane, it ends with the repair: "If the seat was resumed by hand in its session, run: rig reconcile-session <seat>". No text is typed in any of these cases, and rig send --json now exits 1 for them rather than 2.

3 files, +30 -2. No new refusals.
2026-10-26 23:56:50 -07:00
icey 7fa0f386b1 feat(queue): honor parentless escalates_to routes across same-daemon rigs (#1772)
A rig's top-level seat has no delegation parent, so its recovery requests used to fall back to the operator or to itself. The wake ladder and the stuck sweep now honor a declared escalates_to destination, including a seat in another rig on the same daemon, and explain unavailable, ambiguous, self, cycle and unknown-topology fallbacks. Existing delegation parents keep precedence; named targets are kept as topology declarations through import, export, edges and snapshots, and resolved when used. Delivery is one hop per recovery handoff chain, and a consumed recovery uses its own operator continuation without suppressing unrelated work at the same destination.

An already-open recovery keeps its recorded owner even if the originating seat later gains a delegation parent or a changed escalation target; its wake still goes to that owner. Queue waiting and backstop views use the same resolution as delivery.

Implements the scope agreed in #605.

24 files, +1096 -105 (source +364 -90, tests +687, docs +45). No new refusals.
2026-10-26 23:46:39 -07:00
Mike Schwarz e9e18e4e68 docs(roadmap): mark 0.7.3 released (#2161)
The roadmap now lists 0.7.3 as released, with a link to its release and its three themes (participant foundation, attributed shared-channel conversations, actions and perspective for the primary user), in the same shape as the previous release's section. The active section is removed; "How to help", "Testing" and the backlog sections are unchanged. Text only.

1 file, +3 -9.
2026-10-26 23:44:28 -07:00
Mike Schwarz 7e74aefb76 feat(terminal): derive task views from current queue owners (#2131)
Terminal preview and open accept qitem:<id> and show the current owners of that task's active work: pending, in-progress and blocked obligations through explicitly linked descendants, excluding closed predecessors and historical senders. Stopped, missing, external and unresolved owners stay named, and responses say which qitems contributed and report incomplete lineage instead of silently truncating it. Preview and open also return viewDescriptor {kind, name}, so a client can tell a following view from a fixed saved layout.

This derives membership only: no provider terminals are provisioned, no existing workspace is changed, and no agent lifecycle moves. Task views keep the read-only policy of derived views and grant no input. Lineage reads are bounded by explicit row, byte, link and per-record limits, using existing indexes; reaching a limit keeps the known members and marks the view incomplete. No new schema or index.

8 files, +591 -6 (source +261 -6, tests +285, docs +45). No new refusals.
2026-10-26 23:16:44 -07:00
Mike Schwarz da9e404706 docs(quickstart): link the onboarding page and CLI reference relatively (#2158)
The quickstart's "Learn it in this order" links to the onboarding page and the CLI reference are relative, so a reader opens the version they're reading (GitHub resolves them per ref, and the website rewrites them to the release tag) instead of main. Text only.

1 file, +2 -2.
2026-10-26 23:07:38 -07:00
Mike Schwarz 1e2d7b0a89 fix(cli): daemon start --wait-for-kernel says why a skipped kernel is not up (#2156)
When daemon start skipped the kernel on purpose, `rig daemon start --wait-for-kernel` now says why and how to bring it back, with the same message `rig start` gives (one shared helper), instead of a timeout header and an unrelated sign-in hint. The exit code stays 1; the timeout and failure states keep their messages.

4 files, +79 -10 (source +20 -10).
2026-10-26 22:48:47 -07:00
Seán McNamara 0ad6269fdd test: share the clock in compaction restart fixtures (#2149)
The compaction restart fixture gives transport deadlines and the enforcer's simulated sleeps one shared clock (`Date.now` follows the fixture clock), so a simulated wait no longer uses up real preparation time. The spy is restored by the existing cleanup. Test only.

1 file, +2 -0.
2026-10-26 22:30:55 -07:00
Seán McNamara 4c0af6c536 fix: normalize diff prefixes in portability reports (#2148)
The portability report passes explicit `a/` and `b/` diff prefixes to its own `git diff`, so a user's `diff.mnemonicPrefix` setting no longer changes the file names it reports. The test covers both mnemonic-prefix settings with both `core.quotePath` settings. Default-config output is unchanged.

2 files, +4 -3.
2026-10-26 22:30:36 -07:00
Seán McNamara ffd7371e13 test: compare paired CLI symlinks using canonical paths (#2147)
The seat-launch environment test compares the paired CLI symlink's target by its canonical path, the same representation the launcher follows, so it passes where the temporary directory resolves through a symlink. Test only.

1 file, +2 -2.
2026-10-26 22:30:18 -07:00
Mike Schwarz 159a1abd1b docs(skills): say the queue-handoff writing guidance is not a filter (#2155)
The queue-handoff skill's writing paragraph is followed by its qualification: it's writing guidance, not a delivery filter or length limit. Uncertainty and limits that affect the action stay in. Early references, acknowledgements, requested progress updates and completion returns remain fine, and the sender judges how much context is useful. The skill's digest is regenerated. Text only; no behaviour change.

2 files, +3 -1.
2026-10-26 21:55:24 -07:00
Mike Schwarz 65dfcd8c31 fix(daemon,cli): say why a stale kernel stays down, and count a detached session before notifying (#2152)
A kernel that was already down before this daemon start now says why it stayed down, and names `rig up kernel --existing` to bring it back. `rig start`, `rig daemon start --wait-for-kernel` and `/api/kernel/status` show that reason; every other skip keeps `kernel rig already managed`, and `rig start` no longer gives the `--no-kernel` explanation for this case.

The reconciler records a detached session right after its transaction commits, before notifying, so a failure in the event bus's own post-commit work can't leave it uncounted.

6 files, +42 -5 (source +12 -4).
2026-10-26 21:32:12 -07:00
Mike Schwarz c85f9ee795 docs(skills): write queue handoffs to be acted on after one read (#2154)
The shipped `queue-handoff` skill gains one paragraph on writing a handoff the receiver can act on after one read: open with the request or change that affects their next step, use full words, keep the limits that change that step, put reference IDs after the action, and put the complete request near the start of the task body. The skill's digest is regenerated. Text only; no behaviour change.

2 files, +3 -1.
2026-10-26 21:23:03 -07:00
Mike Schwarz 88892bc595 chore: drop internal tracking references from source comments (#2142)
CLI source comments drop internal tracking references. The daemon's copies of the four shared scope classifier files get the same comment edits, so the CLI and daemon copies stay byte-identical. Comment changes only; no behaviour change.

14 files, +24 -28.
2026-10-26 20:57:20 -07:00
Mike Schwarz 5f76c83430 refactor(mcp): neutral names for the external grant service (#2141)
`rig mcp serve --http`'s external grant options take names that say what they do: `--grant-service <url>` and `--grant-service-header-file <path>`, the answer schema `openrig.grant/v1`, and the recorded principal source `grant-service`. An external caller's access and scope use the same wording.

Unchanged: the key and instance patterns, the request and answer fields and statuses, the 503 `grant_authority_unavailable`, every refusal and its code, and the OAuth discovery paths. No old name is kept as an alias. A grant service and an OpenRig on different names fail closed with the existing 503. Rows already recorded keep the principal they hold, because stored principals are read as text and never re-validated.

Restart a running `rig mcp serve --http` together with the daemon. An adapter from before the upgrade keeps working until the grant service's cutover, but its requests are recorded without the grant principal.

14 files, +297 -287 (source and docs +136 -130).
2026-10-26 20:47:54 -07:00
Mike Schwarz a37e4ed60e test: use documentation addresses (#2140)
Tests and one old release note use documentation addresses: `192.0.2.10` (RFC 5737), or a tailnet-range example where a test checks that range. One fetch-guard message no longer names an address. No behaviour change.

8 files, +23 -23.
2026-10-26 20:39:28 -07:00
Mike Schwarz fb551faf76 chore: drop internal tracking references from daemon, tui and ui sources (#2145)
Source comments in the daemon, TUI and UI packages drop internal tracking references, and the regenerated skill and context-pack mirrors follow. Comment and text changes only; no behaviour change. The scope classifier files are unchanged here, so their CLI and daemon copies stay byte-identical.

54 files, +114 -114.
2026-10-26 20:35:19 -07:00
Mike Schwarz 31efb407de test: use synthetic tailnet fixture values (#2144)
Test fixtures use a synthetic tailnet name in the browser-boundary tests and a synthetic tailnet-range address in a posture test, keeping each test's address class. One fixture uses a non-dated example id. Tests only; no behaviour change.

4 files, +7 -7.
2026-10-26 20:16:37 -07:00
Mike Schwarz c30438c410 fix(daemon): restore only a kernel this daemon start found down (#2146)
Daemon start brings back a kernel only when its own startup reconcile found that kernel's seats newly detached, as a reboot or crash leaves them. A kernel that was already down before this start, because an earlier start found it down and left it, now reads stale and stays down, with the reason in the boot log. Before, a later start restored it from an old snapshot and offered to resume its old conversations, even months later.

Startup reconcile reports the sessions it marks detached, and kernel boot receives them. A running kernel, one stopped with `rig down kernel`, an unclaimed one and `--no-kernel` are unchanged.

7 files, +70 -16 (source +24 -7).
2026-10-26 20:06:15 -07:00
Mike Schwarz e6b8086213 docs: describe mcp serve's grant option by what it does (#2139)
The changelog and `rig mcp serve`'s help describe the HTTP grant option by what it does: the not-in-this-release line reads "grants from an external grant service for `rig mcp serve --http` (#2096)", and the help text and startup message say each request needs a key the grant service issues. Flag names, behaviour and the OAuth discovery help are unchanged.

2 files, +8 -8.
2026-10-26 19:44:22 -07:00
Mike Schwarz 09c8e8a6c3 feat(mcp): OAuth discovery and authorization challenges for HTTP MCP callers (#2135)
An OAuth-capable MCP client pointed at `rig mcp serve --http` can now learn how to authenticate from the server itself. With `--oauth-resource <url>` and `--oauth-issuer <url>`, given together and only alongside the external grant service flag, the server answers `GET /.well-known/oauth-protected-resource<resource path>` with the resource, its authorization server, the `openrig.work` scope and header bearers. That happens before the `/mcp` 404 and before any credential or grant check, whatever the Host. Its 401 names that document in `resource_metadata`, and adds `error="invalid_token"` only when a bearer was presented and refused.

Both values must be https, with no userinfo, query or fragment, written exactly as a URL writes them, and the resource path must end in `/mcp`. Anything else is refused at startup, so nothing is rewritten on the way out. Without the two flags, every response is as before. The key, the grant check and the caller's binding are unchanged, and there is no root document, `/mcp` alias or per-tool scheme. The issuer is served exactly as configured, so it should match the authorization server's own issuer byte for byte.

4 files, +186 -11, of which 109 are test lines.
2026-10-26 18:59:43 -07:00
Mike Schwarz c4de1b2c38 build: read leak-guard rules from an optional local file (#2137)
The leak guard's term lists now come from an optional local rules file instead of a file tracked in the repository. Set `OPENRIG_LEAK_GUARD_RULES` (or pass `--rules`) to an absolute path, and every reader uses it through one loader: the leak guard, `build-package.sh`, the context packs, the skill mirror and the control-plane generator. The publishing rules, the frontmatter keys and the internal-section fence stay in the repository unchanged, so mirrored skills are the same.

Without the file, a contributor's `npm run build:package` still works and prints that the term checks were skipped. The release substance gate refuses to run without a rules file, so a release can't pass with the checks silently off. The tracked rules file is removed, and the tests write their own fixture rules.

16 files, +227 -244.
2026-10-26 18:46:46 -07:00
Mike Schwarz 7d809d5456 fix(review): count only open rows as held, and let in-progress work decide doing (#2136)
The review page's agents band and roster tell the truth about what a seat holds. Only open rows (pending, claimed, in progress or blocked) count as held, so a seat's handed-off and done rows no longer show as holdings. When the seat has in-progress work, that's what it's doing. A seat reads as parked only on a blocked row that names a blocker, and only when nothing is in progress, so a closed row's leftover `blocked_on` no longer marks it parked.

The roster's "recently holding" complement uses the same set, so a seat whose rows were all handed off still appears, holding nothing. The other readers of `ACTIVE_STATES` (needs-you and the slice phase) are unchanged.

Reported by @ziadeh.

2 files, +37 -10, of which 24 are test lines.
2026-10-26 18:10:35 -07:00
Mike Schwarz 63fd8fd015 fix(tui): list rigs by name, as rig ps does (#2134)
The TUI lists a host's rigs by name again, in the same order as `rig ps`, in the explorer sidebar and the host views. Before, it listed them in the order the daemon's rig summary serves them, which is creation order.

`hydrateSnapshot` sorts each host's rigs once, where the snapshot is built, comparing names the way SQLite's `ORDER BY r.name` does, so uppercase sorts before lowercase as in `rig ps`. The sort is stable, so two rigs with the same name keep their creation order. A drilled-into remote host's rigs are sorted the same way. Pods, seats and the daemon are unchanged.

Reported by @duncanleung.

3 files, +37 -17, of which 29 are test lines.
2026-10-26 17:32:58 -07:00
Mike Schwarz c178da735d feat(cli): teach a connected member its inbox, and asking a person while keeping the work (#2127)
An assistant connected to an OpenRig team over the MCP server now learns from the connection how to work: its inbox is the items addressed to its seat, a wake only points at the inbox, and it claims an item, does it, then closes it with the answer or hands it on. When an item needs a person's approval first, it keeps the item and asks instead of closing it or acting anyway: it blocks the item on the person who sent it, with the question and a short form of what it will do as the summary, or, for an item from a seat or a shared channel, asks a seat as a new item and blocks on that. The item reads blocked while it waits, and the same item continues after the answer. Both paths are the queue's existing behaviour; the guidance was missing, not the mechanism.

The guidance is one versioned text, member-workflow/v1, joined into the server's MCP instructions, and docs/reference/hosted-members.md quotes it word for word, with a test keeping the two identical. rig_queue_update's blockedOn and rig_queue_list's owned descriptions say the same. A hosted member with no node or session can call rig_whoami and learn the seat it acts as; before, that call failed. A caller connected through an external grant service asks the team for work rather than being a member, so its instructions keep only the shared lines. No new refusal, prompt or required step.

4 files, +154 -10, of which 98 are test lines.
2026-10-26 17:31:19 -07:00
Mike Schwarz 86c184e2be fix(mcp): a Cloud customer can't hand a task to a caller seat (#2132)
An OpenRig Cloud customer's connected assistant now hands its tasks only to the team it was granted, never to its own caller seat or another customer's caller seat. A customer's `rig_queue_create`, or a revision of one, addressed to a caller-shaped seat answers `out_of_scope` with the same message a customer already gets for a person's or another host's address, and nothing is written.

The change is one line: `customerScope`'s `destination` uses the existing `team` predicate (the grant's member seats minus the caller and any caller-shaped seat) instead of `member`. Self-hosted grants, the customer's show, list and artifact reads, the provenance-tag filter and every other tool are unchanged.

3 files, +32 -6, of which 25 are test lines.
2026-10-26 17:24:05 -07:00
Mike Schwarz 6dfa6fe4cc fix(slack): a person asked about a Slack message sees it without OpenRig's agent context (#2124)
When an agent asks a person a follow-up about a customer's Slack message, the person's Slack post now shows the customer's words and the agent's question without OpenRig's agent-only context: the routing footer, a shared channel's envelope (who wrote, the organization, its readable policy) and the attachments block OpenRig generated (workspace-local paths or transfer-failure diagnostics), in the blocks, the fallback text and long-ask parts alike.

The footer and envelope are cut by their last occurrence, since OpenRig always adds them after the sender's words. The attachments block is cut only by the length inbound recorded when it actually generated one (`slack-attachments:<n>`), at a section boundary, starting with OpenRig's own heading. And the projection runs only while the row's body still matches the digest the gateway recorded at creation (`slack-body:<hex>`): a body a handoff replaced or extended is shown whole, so a person never loses authored words. Look-alikes typed by a customer are kept verbatim. The durable row body, stored files, summary, thread, alert and mention are unchanged; there is no new refusal, setting or migration.

Slack messages received before this change carry no digest and are shown whole when later parked to a person.

3 files, +279 -6, of which 223 are test lines.
2026-10-26 16:47:18 -07:00
Wu Shuwen 6acef1be04 fix(restore): retain down-agent resume sources in periodic snapshots (#2123)
When an agent is down at a periodic snapshot tick, the snapshot now keeps that agent's last resume source, so a later `rig launch <rig> <agent>` resumes its conversation instead of coming back `awaiting-decision`.

The snapshot carries the agent's newest session only when it has a resume token and a `resume_if_possible` policy, and one source is chosen before FR-7 and passed into `postLaunchRestore`; the carry is revalidated before any launch effect. It applies only to a named agent's automatic periodic restore: an explicit `--snapshot-id`, `--fresh`, a whole-rig restore and `selectedHistory` keep their meaning, an agent without a token still starts fresh, and 0.7.2 still reads the new field. The idle-shell message points to the command that actually resumes.

Reported by @pr0xc3nt4ur1 in the linked issue, and built by @dajiaohuang, who traced the restore path past the early refusals first.

8 files, +407 -21, of which 290 are test lines.
2026-10-26 15:04:30 -07:00
Mike Schwarz e66cabeeb9 fix(cli): a Cloud customer's create can't claim OpenRig's Slack provenance tags (#2126)
When an OpenRig Cloud customer hands the team a task and the team later asks a person about it, that person now sees the customer's whole text. Before, a customer could tag its row with OpenRig's own Slack provenance tags, or with the workflow sign-off tag `gate`, so the person-facing view showed only part of the row, or only its summary, while the agent read the whole body.

The MCP server's `rig_queue_create` now drops, for a validated Cloud customer grant only (create and revision), the tags `founder-slack`, `inbound`, `shared-channel` and `gate`, and every `participant:` and `slack-` tag. The customer is identified only from the validated grant. The body, summary, other tags (including `workflow`, `workflow:` and `instance:`) and the request's success are kept; local, stdio and token-file callers and Slack ingestion are unchanged; there is no new refusal.

3 files, +80 -2, of which 71 are test lines, including real daemon routes where the person's render keeps the full body.
2026-10-26 14:51:42 -07:00
Mike Schwarz f5609a88bd fix(cli): serve a Cloud caller seat that was created but never launched (#2125)
An OpenRig Cloud customer's caller seat that was created ahead of time but never launched now works. Before, the MCP HTTP server asked the daemon's `whoami`, which knows only seats that have run, so a pre-created seat was refused with `seat_unavailable` before any tool.

Now, only when `whoami` answers 404, the server reads the daemon's own rig and node lists and serves the seat when exactly one existing node's canonical name equals the seat the Cloud grant names. Two active rigs or two nodes with that name, a malformed rig-list entry, a failed or timed-out read, and any other `whoami` error are refused with the existing `grant_authority_unavailable`; a missing seat or a rig that exists only archived reads `seat_unavailable`. Nothing is created, launched or bound, and the grant, instance and revocation checks, customer scope and `principal:v1` are unchanged. The shared rig-name resolver gains an opt-in strict mode that only this check uses.

5 files, +275 -7, of which 227 are test lines, including a real-daemon test with a created, never-launched caller.
2026-10-26 13:49:42 -07:00
Mike Schwarz 6778fcc496 feat(cli): show when a hosted member last called, and its latest ring (#2121)
`rig_team`, `rig roster show` and `rig roster find` now say when a hosted member last called the team's MCP server, and the outcome of its latest ring. When there's no evidence, the answer is unknown, never idle. A roster row for a member on another host reads unknown instead of borrowing this host's record.

After a caller's grant authenticates, the MCP HTTP server writes the seat, the grant's name and the time to `mcp-http-presence.json`: mode 0600, beside the token file and never in it, at most once a minute per seat. An unauthenticated or failed request writes nothing, and a failed write never fails the request. The latest ring is read from the member's recent rows. Callers using Cloud grants have no presence.

The hosted-members guide now says a webhook receiver should tolerate a repeated `qitem_id`.

10 files, +207 -13, of which 96 are test lines.
2026-10-26 12:11:20 -07:00
Mike Schwarz 22116a78d9 fix(pi): recover a saved history cursor Pi doesn't hold, and take Pi's cursor only from history (#2122)
A Pi seat whose saved history cursor isn't one Pi holds, such as an interface event id saved by OpenRig 0.6.11 to 0.7.1, now recovers it at the next start. When Pi refuses the history catch-up, the runner prints that the history catch-up didn't run, keeping that apart from whether the seat resumed, and re-reads the cursor from the tail with the same read-only refresh it already sends after every turn. Nothing is reset or replayed. It no longer prints a runner error and re-sends the same cursor at every restart until a turn completes.

On Pi, an event's own id no longer becomes the saved cursor; an explicit history entry id still does. OMP is unchanged.

Reported by smota in Discussion #2117.

2 files, +48 -2, of which 37 are test lines.
2026-10-26 11:39:47 -07:00
Mike Schwarz d3c7d790c5 fix(slack): route a reply on a self-parked ask to its asker, and never post a Slack message back as an ask (#2119)
When an agent parks its own ask on the person it asked, OpenRig's Slack side now takes the agent from the row's source. The park's post stays in the ask's thread instead of opening a new top-level post, and a colleague's reply in that thread reaches the agent who asked, attributed to the colleague. The ask stays parked on the person until they answer, and their own answer still resolves it.

A Slack message routed to a person is recorded for Steering and the TUI when it arrives and is no longer posted back to Slack as a new ask carrying OpenRig's routing lines and the shared channel's policy text. If an agent later parks that message on a person, that park is a new ask and is posted as usual.

Threads whose root was recorded under the person before this change keep routing replies to that person.

4 files, +172 -4, of which 153 are test lines.
2026-10-26 11:18:13 -07:00
Mike Schwarz 457aa6d002 fix(plugin): advance main metadata past released core payloads (#2118)
Main's openrig-core plugin manifests move from 0.1.17 to 0.1.21, and the packaged-plugin baseline fixture moves from 0.7.1 to the published 0.7.2 archive (plugin 0.1.19). The plugin's payload on main has changed since both published 0.7.2 and the 0.7.3 cut, so a build of main now passes the stock packaged-plugin version check instead of failing it.

3 files, +6 -6, metadata only.
2026-10-26 11:11:10 -07:00
Mike Schwarz c0fe03ad04 fix(usage): keep a seat's current Claude or Codex cost, and finish a charge lookup retried during a re-post (#2120)
With Pi cost reporting turned on, a seat name that once ran Pi and now runs Claude Code or Codex shows its current cost in `rig usage cost` and the TUI's Cost view, not its old Pi estimate: a stored Pi session replaces the seat's entry only when it's newer than the seat's latest context sample. An OpenRouter charge lookup that is retried while unacknowledged outcomes are being re-posted now finishes once the re-post ends, instead of staying pending until the seat's next turn. Both live behind the off-by-default `usage.pi_cost_facts` and `usage.pi_openrouter_charge` settings.

Found by oSquashBlossomo's review in their fork's PR #53.

4 files, +55 -2, of which 34 are test lines.
2026-10-26 11:04:06 -07:00
Mike Schwarz b72abee350 feat(daemon): ring a hosted member's webhook when work lands for it (#2115)
A hosted member, an outside tool on the team whose roster entry declares a webhook trigger, is now rung when work lands for it. The daemon POSTs a pointer to the row (its id, destination, sender and summary, never its body) to the member's declared webhook, taking credentials only from secret refs. Credentials in the URL are sent as a Basic Authorization header, and no credential appears in any recorded result or posted finding.

An accepted ring is recorded as accepted, not as picked up, on the wake that rang. A daemon restart doesn't ring that same accepted wake again, while a new wake for the row, such as an unblock or a resolve decision, still rings, and a failed ring is retried. A member whose entry declares no trigger stays unroutable, so its person is told, as before. `rig queue create --verify` says the trigger accepted the ring, not that the member picked it up.

14 files, +608 -75, of which 316 are test lines.
2026-10-26 10:47:36 -07:00
Mike Schwarz 5219120dd3 docs(harness): check that a harness answers on its own before writing its preset (#2116)
Before writing a preset, a community-harness author now checks that the harness answers one prompt on its own, outside OpenRig, using the folder the launcher gives the seat rather than the harness's default one. A provider, model or login problem then shows up in the harness itself instead of costing repeated seat relaunches, which can't fix it. "Write your own" points back to the step.

1 file, +9 -1, text only.
2026-10-26 10:11:51 -07:00
NIKHIL 4cc91e914e fix(review): keep asks to and parks on a person's own address in NEEDS YOU (#2108)
An ask to a registered person's own address, such as `sarah@external`, and a row parked on it now show in NEEDS YOU in the slice, mission and rig reviews (`/api/review/*`, read by the web review pages and the TUI), and the person is no longer listed as an AGENTS row holding that work. The reviews ask the queue's own participant facts, so a registered alias such as `sarah@host` counts too.

A registered shared channel takes correspondence, not decisions: a row sent to or parked on one never appears in NEEDS YOU, whatever its tier, and the channel isn't an AGENTS row. Typed gates such as `external:qa-pass` stay out of NEEDS YOU.

3 files, +110 -17, of which 47 are test lines.
2026-10-26 09:57:14 -07:00
NIKHIL 860e8d4c4d fix(daemon): verify Claude handover packet submission (#1863)
A fresh `rig handover --source fresh` on Claude no longer reports the restore packet delivered just because paste and Enter returned. The daemon reads Claude's composer after Enter. A submitted packet is reported delivered. One still staged gets a single Enter retry on complete staged-text evidence, and if it stays staged the handover fails with `context_delivery_failed` and the successor is unwound. When the composer can't be observed or the result is unclear, the handover succeeds with `restorePacket: { status: "unconfirmed", reason }`.

`rig seat handover` and `rig handover` say the packet was sent but its submission is unconfirmed, give the reason, and say to check the successor before sending the context again. Rebuild handovers and the rest of #1544 are unchanged.

7 files, +213 -16, of which 101 are test lines.
2026-10-26 09:28:05 -07:00
NIKHIL 563f1a2470 fix(daemon): exclude artifacts with unclosed frontmatter (#2016)
An artifact whose frontmatter opens with `---` but never closes is now treated as malformed frontmatter and excluded from artifact-ready wakes, so an unfinished file no longer wakes an unfiltered artifact pool early. A file that uses a leading `---` line as a horizontal rule is excluded the same way; the option's documentation says so. An edge target with an incomplete header still counts as existing, so a missing-downstream-artifact repair wake isn't manufactured for it.

3 files, +59 -2, of which 52 are test lines.
2026-10-26 09:09:54 -07:00
Nash Aschenbath 23070b6465 fix(claude): keep activity consumers in source order (#2113)
A delayed older Claude hook no longer replaces the current state in send readiness, attention clearing, review telemetry or the wake scheduler. An old `Stop` arriving after a current limit can no longer permit a wait-for-idle send, and an old `Stop` arriving after a permission prompt can no longer settle that prompt's open wake alert. These readers now use the activity store's existing source order for Claude, as the limit readers do since #1908. Other runtimes keep arrival order.

9 files, +141 -9, of which 113 are test lines.
2026-10-26 08:56:35 -07:00
Brad Vincent db90c6dad3 feat: resume explicitly reviewed history by node ID (#2109)
An agent's reviewed history can now be resumed by node ID. After `rig seat history` returns a history for an exact node, a client asks the startup review for it: the review names the node, the chosen snapshot or history, the planned settings sources and an opaque revision. Apply then rechecks all of them and resumes exactly that conversation through the existing restore engine. If anything changed, it asks for a new review instead of resuming something else, and it never starts a fresh conversation in its place.

The result reports what actually happened. A resume that ran but couldn't be confirmed reports `resume_unconfirmed`, and a permission that can't be resolved reports `permission_unavailable`. Default startup and restore are unchanged for existing callers.

This is the last of #1837's three stages, after #2051 and #2100. 18 files, +1056 -51, of which 621 are test lines.
2026-10-26 08:55:42 -07:00
Mike Schwarz e37443de4b feat(mcp): serve MCP 2026-07-28 clients beside 2025 ones (SDK 2.x) (#2112)
OpenRig's MCP server now also serves clients on the 2026-07-28 MCP protocol: they don't send `initialize`, carry the version on each request, and may open with `server/discover`. Those clients reach the same tools and durable work loop over stdio and `rig mcp serve --http`. Clients on the 2025 protocol keep working on the same endpoint and process.

It moves from the 1.x SDK and its Express stack to `@modelcontextprotocol/server` 2.4.0, `core` 2.4.0 and `node` 2.1.2 (Apache-2.0). The 1.x SDK will not get the 2026-07-28 protocol. Token-file grants, Cloud grants and customer scope, principal attribution and the loopback Host check stay in front, unchanged.

Behaviour changes every client sees in `tools/list`: each tool's input schema is now advertised as JSON Schema draft 2020-12 (was draft-07), and the `execution.taskSupport: forbidden` field is no longer sent.

7 files, +870 -346: the 36 tool registrations move mechanically to `registerTool`, tests +151, and the lockfile accounts for most of the rest.
2026-10-26 08:37:58 -07:00
Mike Schwarz 111b51fec5 fix(daemon): tell the person when work waits for a member nothing can wake (#2110)
When work waits for an address nothing can wake, such as a hosted member that lives outside a terminal or a mistyped address, the stuck sweep now tells a person instead of the address that can't hear it. It tries the operator seat when that's a person, else the row's creator, else the operator agent, and skips any that is unset, the same address, missing or itself unreachable. The finding says why, and that a hosted member sees the work only when it next checks.

A seat that has launched since its wake keeps its own finding. The finding still closes when the work is claimed. Nothing new is refused.

3 files, +91 -1, of which 64 are test lines.
2026-10-26 08:25:32 -07:00
Mike Schwarz e08d084465 docs(claude): name the other-login helper case for rate-limit attribution (#2111)
The Claude status-line reference now names the other-login case for rate-limit attribution. A helper Claude started from a seat's shell on a different login is attributed to that seat, like a same-login helper, 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.

Docs only, one file, +5 -2.
2026-10-26 08:20:03 -07:00
NIKHIL d2a73083dc fix(slack): a park on a person's own address answers only from a reply after its question (#2106)
When a seat parks a row on a person's own address, such as the `<id>@external` address their inbound Slack rows carry, only a Slack reply written after the current question posted now resolves it. That's the reply-ordering rule legacy human seats (`human@kernel`) already follow. Before this, a registered person's address skipped the check, so an early follow-up could answer a question asked later.

The person's earlier messages still reach the agent as context; only the automatic resume waits for a reply after the question. The resolver uses the queue's existing person-address check, so a registered alias counts too.

2 files, +62 -3, of which +3 -2 is product code.
2026-10-26 08:15:36 -07:00
Nash Aschenbath 2adde8ffe0 test(ci): exercise seat environments on Linux (#2101)
CI now runs the Linux-only seat-environment test in its own job on pull requests and manual dispatch. The macOS package matrix skips it because it reads `/proc/<pid>/environ`, which left #2041's launch paths untested there. The test also checks that new and existing tmux servers give seats the runtime directory, the disabled session-bus default, and tmux's own terminal identity rather than the daemon parent's.

The job has read-only contents permission and no secrets, and takes about 90 seconds. The required checks are unchanged.

3 files, +61 -2.
2026-10-26 07:57:26 -07:00
Nash Aschenbath 2ed390de39 fix(claude): detect typed rate-limit interruptions (#1908)
A Claude seat that hits a rate limit now reads as at its limit, from Claude Code's typed `StopFailure` hook with `error: rate_limit`. OpenRig never reads the error text. The wake ladder then holds the seat's handoffs for its existing retry interval instead of typing into a stopped seat. Limit events are ordered by when the hook fired, so an older event arriving late doesn't clear or reassert a limit. When seats sharing a Claude home report conflicting limit windows, only the failing seat is held.

Known limit: a helper Claude started from the seat's shell inherits the seat's environment, so its rate-limit hook is attributed to the seat. On the same login that's right, since the quota is the account's. A helper on a different login can hold the seat's handoffs for one retry interval. Binding the hook to Claude's own session id would remove that.

17 files, +533 -47, of which 361 are test lines. The plugin version is unchanged.
2026-10-26 07:55:44 -07:00
NIKHIL 47fe5b7325 fix(queue): keep a park's summary and evidence when it parks on a registered person (#2107)
`rig queue block <id> --on sarah@external --summary "…" --evidence-ref …` now parks the row and keeps both fields when `sarah` is a registered person, as the same park on `human@kernel` already does. Before this it refused with `summary_evidence_not_persistable`. The check uses the queue's existing person-address predicate, so a registered person's address or alias counts. A bare park on a person still needs nothing new, and other transitions still reject the fields.

2 files, +26 -2.
2026-10-26 07:54:25 -07:00
Mike Schwarz 4c34d13cae feat(daemon): record which MCP grant's caller acted (principal:v1) (#2098)
`rig mcp serve --http` now tells the daemon which grant's caller acted, and the daemon records it beside the rows that caller writes (identity_provenance `principal:v1`, plus a nullable identity_principal column, migration 140). A token-file grant is recorded by its name. A Cloud customer is recorded by id, grant id and version. The principal is sent only to an endpoint proven to be this host's own daemon (its /healthz self id, loopback with no forwarding headers), and recorded only on direct hops. Draft-aware rows keep it.

The header carries non-ASCII names as JSON escapes, so a token-file grant named in any script keeps working. Rows written another way are unchanged.

20 files, +557 -58, of which 315 are test lines.
2026-10-26 07:43:36 -07:00
Mike Schwarz 62169765b2 docs: carry 0.7.3's capability map and changelog section to main (#2105)
Carries 0.7.3's capability map, with its `capability-delta-v0.7.3` marker, and the CHANGELOG 0.7.3 section from the release branch to main, so the next release builds on what 0.7.3 teaches. Both files are byte-identical to the 0.7.3 candidate (30a4ea13), and neither had changed on main since the cut. The CHANGELOG section is the candidate's text; the exact published notes are reconciled after publication.

`docs/reference/whats-new.md` waits for the version bump after publication, because a repo test checks that it names the packaged version. Versions, install pins and the plugin manifests also wait for the tag.

2 files, +456 -9, docs only.
2026-10-26 07:27:55 -07:00
Mike Schwarz c3c404f10e docs: state the 0.7.3 limits where people rely on them, and list rig seat history (#2104)
Docs only. These are the gaps the 0.7.3 docs check found, fixed on main:

- The Hermes preset and its example no longer say a stopped seat's gateway exits once idle; that hasn't been checked, so they say to check with `ps`.
- The Codex permission envelope in `rig-spec.md` says OpenRig's tests prove the launch flags, not that Codex enforces them, and that an OpenRig older than 0.7.3 launches the seat without the envelope.
- Stopping a community seat says whether the harness's own processes survive isn't exercised, so check them after `rig down`.
- `getting-started.md` and `help.md` say `rig setup` and `rig doctor` run the tmux capture check (`tmux_capture`).
- `docs/as-built/cli-reference.md` lists `rig seat history`.

6 files, +18 -9.
2026-10-26 06:57:45 -07:00
Mike Schwarz 43f3c9097b fix(cli): keep tmux's errors off the terminal during identity lookup (#2103)
`rig down`, `rig whoami`, `rig ask` and `rig health`, run in a tmux pane without OPENRIG_SESSION_NAME, no longer print "invalid option: @rigged_node_id" and "@rigged_session_name" lines. Those were tmux's own error output from the identity lookup asking for an unset option; the lookup already fell through correctly, and the error still reaches the code that handles it.

The OpenClaw native run found it. It has been there since rig down's own-rig check (#1838, in 0.7.2). CLI only, +21 -1, with a test that runs real tmux against an empty socket directory.
2026-10-26 06:51:23 -07:00
Mike Schwarz 7ec3121104 fix(tui): don't count a refused effort report as a failed read (#2102)
The TUI no longer shows "1 read(s) failed" on a seat whose runtime doesn't report effort, such as a community harness. The daemon's 409 effort_unsupported_runtime is an answer, and the panel already showed it correctly; the page reader just counted it as a failed read too. A real server error still counts.

The OpenClaw native run found it. It has been there since the effort read was added (#1529, in 0.7.2). TUI only, +18 -2, with one test for both the refused and the failing case.
2026-10-26 06:41:31 -07:00
Mike Schwarz cd850f0e54 feat(mcp): show a rig's hosted members on its team, and how a member works its rows (#2095)
A tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, can now be seen as a member of a rig's team. A roster entry can say it's hosted (`"hosted": true`), and `rig roster show` and `find` then report the member as hosted rather than unmatched. `rig_team` lists the rig's hosted members with their authored capabilities and limits, no pane, and activity `unknown`: OpenRig doesn't observe such a member between calls, so it never reads idle. A roster folder or file that can't be read gives a generic `hostedMembersNote`, never an empty list.

The MCP server's instructions now name the loop a member uses on its rows (list, claim, then answer with a resolution or a handoff). Addressing rows to the member and working them under a grant already worked; this adds the team view and the guidance.

9 files, +258 -12. Docs: `docs/reference/hosted-members.md`.
2026-10-26 06:15:55 -07:00
Brad Vincent a2dfc69bbf feat: inspect captured resume histories by node ID (#2100)
`rig seat history <node>` lists an agent's captured startup histories for an exact node ID and validates a chosen one, in text or JSON. Each entry shows its resume type and saved token state, never the token. Eligible means the engine can attempt that resume, not that the native conversation still exists. A reference that is invalid or no longer current is refused rather than replaced with another history. Codex and Claude are covered first.

It reuses startup's current snapshot lookup, its restore-preview classification and each adapter's resume check, and doesn't launch anything or call a native loader. Existing startup and default restore behaviour are unchanged for valid inputs; malformed imported metadata now reads as unverified instead of crashing discovery.

This is the shared-selector stage of #1837. Applying a chosen history is a separate later change.

10 files, +637 -29, of which 394 are test lines.
2026-10-26 05:53:39 -07:00
Mike Schwarz 7d4946e935 feat(mcp): grants from OpenRig Cloud for rig mcp serve --http (#2096)
`rig mcp serve --http --cloud-grants <url>` takes its grants from OpenRig Cloud instead of the local token file, for a Core instance whose callers are Cloud customers. Each request's customer key is checked against Cloud for this daemon's own instance id. Any answer but active is refused (401, or 503 when Cloud is unavailable), with no fallback to the token file. A customer grant reaches only the queue and artifact tools it names, sees only its own items and the team's questions to it, and every row it receives drops the parties outside its scope. Self-hosted token-file grants are unchanged.

The caller-seat create refusal (78f426ba) is reverted in this merge while it waits on a product decision; it comes back by reverting 0f93cfed. Named limits: the caller-seat read rule rests on Cloud's one-customer-per-rig provisioning contract, not general tenant isolation, and matching the instance is a configuration binding, not cryptographic authentication.

5 files, +765 -50, of which 374 are test lines.
2026-10-26 05:34:07 -07:00
Mike Schwarz ffdf0a3a8a fix(tui): don't draw into a zero-size terminal (#2099)
`rig` or `rig tui` started in a terminal that reports 0 rows or columns, such as a detached, headless or just-resizing pane, no longer crashes on its first frame. It skips that frame and draws on the next resize. A size the terminal doesn't report keeps the existing defaults.

The crash has been there since 0.7.2. TUI only, +13 -3, with a stub test at 0 rows and 0 columns.
2026-10-26 05:03:08 -07:00
Mike Schwarz bfaa244831 docs: a Hermes gateway runs per Hermes home, so per seat with the example launcher (#2097)
The Hermes example config and the community-harness presets guide now say that Hermes runs tool commands in its gateway, one per Hermes home, so one per seat with the example launcher. They said "one gateway per machine", which a two-seat run disproved: each seat had its own gateway, home and socket, and stopping one seat left the other running.

Docs only, two files, +5 -2. Hermes resume remains undeclared.
2026-10-26 04:51:43 -07:00
Mike Schwarz 4b515ecdbf fix: show unrecovered OpenClaw local turns as attention (#2092)
An OpenClaw seat whose local turn was interrupted, and which now shows OpenClaw's "Session … changed while starting work. Retry." frame, reads as needing attention on nodes, `rig ps` and the TUI, with "OpenClaw turn not recovered; inspect the pane before retrying". A send to it still goes through, as today, with an advisory that OpenClaw refused its last local turn and that `rig capture <seat>` shows it.

The recognizer reads only OpenClaw's own footer above the editor, so pasting the error into the input box doesn't produce the diagnosis. It observes; it doesn't repair native records, replay the request or start a fresh conversation. The presets guide recommends Gateway mode for unattended seats. 0.7.3 slice 06, OpenClaw (a).
2026-10-26 03:56:01 -07:00
Mike Schwarz ef668abd27 fix(daemon): say a replaced Codex conversation isn't a binding change (#2094)
When a wrapped Codex seat's pane holds a conversation other than the one its verified process owned, the generation-record route now says so: "The Codex conversation in the pane for '<seat>' isn't the one its verified process owned, so no generation record is served." `rig walk` prints this verbatim. Before, the case reused "The occupant binding changed while resolving", which was untrue for it.

Same `record_identity_unverified` code and 409 status; the binding re-checks are unchanged. Follows #2084 (Aschenbath).
2026-10-26 03:40:20 -07:00
Mike Schwarz 63220d7d8c fix: report advancing past an unanswered human workflow gate (#2093)
A workflow whose done or handoff advances past a declared human park that nobody answered now says so in the existing advisory output and in the same transition note, on both the serial and dependency-graph paths. Nothing is refused: the step still proceeds.

The shipped `messaging-the-human` skill (all three copies) and the resolve nudge say that a response is not consent or authority, and show `--exit waiting --blocked-on <person>` for waiting on a person. Refs #1710.
2026-10-26 03:25:27 -07:00
Mike Schwarz b1c0860580 test: exercise community harness coordination and resume contracts (#2088)
The community-harness contract in `docs/reference/community-harness-presets.md` now has a test behind each claim with no native proof yet: delivery, refusing a shell or a numbered choice, an unrecognized prompt reading as unknown, no approval relay and no cancel verb, startup files not placed, the preset's own model, named and reported resume never starting a fresh conversation, stop and down leaving a sibling session untouched, and a single launch after an unconfirmed identity.

The survival of processes a harness starts outside its pane is not exercised. Test-only: six daemon test files, +286 -2.
2026-10-26 03:07:44 -07:00
Nash Aschenbath 89687bb257 fix(daemon): accept generation records for wrapped Codex (#2084)
A verified Codex seat launched under a shell or launcher gets its generation record again, so `rig walk` can check consumption instead of always reporting it unverified. The route compares the live pane root with `observedPanePid ?? observedPid`, the rule the current-generation helper already uses for Claude.

For a verdict that records both PIDs, the thread selected under that root must also be the thread resolved for the verified native process. A different Codex conversation started beneath a surviving wrapper is refused with the existing `record_identity_unverified` 409. Legacy verdicts that recorded one PID keep their previous check.

The diagnosis is @fsafey's, in #1799.
2026-10-26 03:05:36 -07:00
Nash Aschenbath b4cb4ea318 fix(daemon): preserve pod startup files in rig export (#2087)
`rig export` now writes each pod's saved startup files under that pod, keeping their order, delivery hints, required flags, orientation and `applies_on` selections. Previously those declarations were dropped, so a re-imported rig could start members without their pod guidance. The CLI checks required rig and pod files against the output folder and warns once per missing one.

Files are referenced, not copied: when exporting to another folder, supply them there or import with the original `--rig-root`. Rigs with no saved pod file list keep their existing export behavior. Pod startup actions and member startup blocks remain separate work (#1631 stays open for them).
2026-10-26 02:54:33 -07:00
Samuel Smith 5ff78626b9 test(daemon): remove real startup delays from mocked fixtures (#2091)
Daemon tests that use a mocked terminal no longer wait on real 200 ms startup sleeps. The existing `sleep: async () => {}` hook is injected at 14 `StartupOrchestrator` construction sites in 11 test files. No production code, assertion, timeout or concurrency setting changes. In the author's measurement the affected files' wall time fell by 84%.

Test-only, +15 -14.
2026-10-26 02:42:35 -07:00
Mike Schwarz ba45f8ec61 feat(slack): show the asks still open after enable (#1679) (#2086)
`rig slack enable` treats the asks already open as seen, so none of them post: enabling never replays a backlog. `rig slack post-open-asks` now brings those asks into Slack through ordinary delivery, at the level each was asked at (quiet hours, digest and the posting floor apply), and says what became of each one: posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer active, superseded, recipient changed, state unavailable, transport failed or uncertain. Running it again reuses each ask's one decision, so a confirmed post isn't repeated; one whose earlier attempt Slack couldn't confirm may post once more, marked as a possible repeat.

State that can't be read is never taken as absent. An unreadable dispatch buffer, seen store or attempt record reads as state unavailable or uncertain, and an attempted re-shown ask whose Slack outcome is unconfirmed, or whose attempt log can't be read, stays in the buffer for its next replay instead of being recorded as not posted. Named limits are in the PR body.

The need came from rakoort's report in #1679.
2026-10-26 02:34:27 -07:00
Mike Schwarz 2bf7db3e17 docs(send): separate work goes in a queue row; --wait-for-idle sends nothing when the wait ends busy (#2089)
A sender who wants a busy Codex seat to do separate work is now told to create a queue row (`rig queue create --destination <seat>`) rather than to wait for idle. A saved row keeps the work apart from the short wake typed into the seat, and its state, not the wake, says whether the seat claimed or finished it.

`--wait-for-idle <seconds>` is described as it behaves: 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.

Text only, in the send advisory, `docs/reference/seat-delivery.md`, `rig send --help` and the MCP send tool: +13 -4.
2026-10-26 02:19:13 -07:00
Mike Schwarz 427bf2852a docs(startup): an exact resume delivers no startup actions, and a managed Claude seat keeps its session name (#2085)
A spec author can now tell from the docs what a restored seat receives. An exact resume (`rig up <rig> --existing` reopening the saved conversation) types no startup actions and delivers no startup files, `restore` ones included, apart from putting back a missing managed guidance block. `restore` items apply when a seat comes back without its earlier conversation: a resume that falls back to a fresh one, or a retried start.

The startup guide also says not to rename a managed Claude seat. OpenRig starts every Claude seat with `--name <session>`, and context usage, managed compaction and input-box recognition read the seat by that name, so a startup `/rename` breaks them until the next resume.

Docs only: `agent-startup-guide.md` and `rig-spec.md`, +12 -5.
2026-10-26 01:51:14 -07:00
Mike Schwarz 1815055281 feat(capture): rig capture --readable reads a small seat at a readable size and gives its window back (#2075)
`rig capture <seat> --readable` (or `--size WxH`) reads a seat whose pane is too small to read. It resizes the seat's window for the read and gives it back afterwards, through the same capture path.

The resize joins #2065's window pin record, so a web terminal opening during the read keeps its own pin, and the size is put back only by the last live holder. A pin left behind by a crash is repaired by the next capture. Nothing resizes without a written record, and one 8 s budget per request bounds a `--rig` or `--pod` fan-out (the CLI waits 15 s); after it runs out, seats read as they are, with a reason.

The cost: the seat's TUI redraws twice, and a viewer sees the resize.
2026-10-26 01:32:10 -07:00
Mike Schwarz bdcb4821b6 fix(tui): open terminal views in cmux when the TUI runs inside cmux (#2080)
Someone running the TUI inside cmux who uses a rig's `term ▸` link or **Open terminals** now gets the view as a new cmux workspace, the same result as `rig terminal open <view> --provider cmux`. Before, the TUI always ran `--window`, which never reaches cmux.

Detection needs `CMUX_SOCKET_PATH` and `CMUX_SURFACE_ID`, which the daemon's own environment scrubs, so a shared TUI in the daemon's tmux isn't read as cmux. Herdr or Rex inside cmux keep their view. A cmux preview sends no width, so the kernel view's plan matches its open, and a refused cmux socket names cmux's Automation socket mode. Outside cmux, nothing changes. Refs #1069.
2026-10-26 01:26:50 -07:00
Mike Schwarz 69c2061a21 feat(cli): setup and doctor check that tmux can capture a pane (#980) (#2081)
A person whose tmux crashes on `capture-pane -p` now learns it from `rig doctor` (a `tmux_capture` failure) and `rig setup` (its tmux step), with tmux's own error, why it matters and the fix. Before, every seat failed at launch with "server exited unexpectedly".

The check runs the getting-started guide's existing command on a throwaway socket with no config, so a person's own sessions are never touched. Every command is bounded, and the throwaway server always gets a cleanup attempt. A server that won't start reads as not checked, not as a failure. There's no minimum tmux version. Refs #980.
2026-10-26 01:26:09 -07:00
Mike Schwarz 0cb8ffb2ca docs: what a seat is set to versus what it runs, and a newcomer's reading order (#2079)
The agent startup guide gains one table: what a seat is saved to use at its next launch, what the running seat actually has, and the action that brings a saved change to it, for model, reasoning effort, permissions, working directory, startup files and skills, and tools. Without live evidence it says unknown or not verified, never the saved value.

The quickstart gains a reading order for newcomers: how OpenRig works, first use, `rig <command> --help`, then the CLI reference.
2026-10-26 01:00:32 -07:00
Mike Schwarz 3d94a39d7c docs(reference): clarify A2A walkthrough setup and worker wake (#2082)
The A2A walkthrough now fixes the five problems a cold read found. Start the sample peer yourself in a separate terminal (its address exists only while it runs); fetch the pinned sample commit; when `A2A_TEXT` is unset, the row body becomes the peer's message verbatim; the example asks for an exact reply; and a worker is woken with `rig send`, or by removing `--no-nudge` to get the normal queue wake.

No new interoperability claim is made.
2026-10-26 00:59:39 -07:00
Mike Schwarz 16d2f01b15 docs(slack): one continuing conversation keeps earlier context, whoever writes (#2083)
The Slack setup guide now says that 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. A conversation shared on purpose is fine.
2026-10-26 00:56:25 -07:00
Mike Schwarz 801779fbb4 fix(attention): the TUI shows the person at the terminal acting on a channel's row, and a channel never acts (#2068)
With a shared channel registered and no person, OpenRig records an act on the channel's ask as the person at the terminal (`human@host`). The Feed now serves that same actor on a channel's ask or a row parked on one, so the TUI's label, its success notice and the record agree.

A request that names a shared channel as the actor, without the terminal's primary-user authority, is now refused for drop, hold, route, handoff and annotate, as it already was for answer, approve, deny and resolve. Nothing is recorded. With the authority, a channel claim is replaced by the primary user for every verb, as before. Agents act as their own seat, so their routing is unchanged.

Left as is: if `participants.yaml` can't be read, no shared channel is known, so only asks and replies from before the file broke are exposed. The remedy is repairing the file.
2026-10-26 00:51:43 -07:00
Mike Schwarz d8eea0208f test: wait for private tmux children before scratch cleanup (#2070)
The hermetic test helper no longer reports cleanup complete while its private tmux server's pane children can still write to, or recreate, the scratch HOME.

It records the server's panes and their existing descendants before the signal, waits a bounded 2 s without signalling any other process, then removes the root. An unknown process identity or a timeout keeps the fixture, and a failed cleanup stays failed on later calls. The new tests keep their own startup under teardown, and a repeated hangup no longer ends the fixture child before its final write. Test layer only; no production code changes.
2026-10-26 00:45:35 -07:00
Mike Schwarz 737df51927 fix(daemon): rig down says a community seat's own processes may outlive its pane (#2077)
`rig down` now names every community-harness seat whose pane it stopped: "Pane stopped: <seats>. A community harness's own processes may keep running after its pane; see its preset's limits." It also reports them in a new optional `paneOnly` JSON field. Before, a Hermes gateway that outlived its pane was silent.

OpenRig still stops only the pane, and only seats launched for a community harness are named, not a built-in runtime or a free-text namesake. Part of 0.7.3's Hermes work (slice 07).
2026-10-26 00:30:10 -07:00
Mike Schwarz c2a7858000 Revert #1345 (native version-label witness) on main, so release cuts need no inverse (#2076)
This reverts #1345, the native Claude version-label witness, on main. Every release since 0.7.0 has shipped with it reverted, so main now matches what ships, and a release cut no longer needs its own hand-made inverse of it.

All nine files are patch-identical to the inverse in published 0.7.2. The two conflicts are resolved to keep every later change to claude-code-adapter.ts and startup.ts. Nobody running a published release loses anything. Re-enabling it later is a revert of this revert, once its native qualification (#86) answers.
2026-10-26 00:21:36 -07:00
Mike Schwarz f7826a3db3 docs(harness): what a community harness gets, how to write one, and the promotion test (#2078)
The community-harness presets guide now says what a contributor's harness gets from OpenRig, capability by capability: launch, delivery, identity, resume, activity, attention, approval, cancel and transport. Each is marked supported, when declared, partly or no, and each says what the person sees instead. Anything unsupported reads as not known, never idle or ready.

It adds a five-step walk-through for writing your own preset over the Cursor, OpenClaw and Hermes examples, and the promotion test. Promotion to a built-in runtime is decided by the OpenRig maintainers, on evidence from OpenRig's own testing. Passing the checks isn't automatic promotion or a promise of upstream support.
2026-10-26 00:16:43 -07:00
Mike Schwarz f8a985b4ce fix(cli): a Slack channel-map change says to restart the daemon, not disable then enable (#2072)
After `rig slack channel-map set` or `remove`, the CLI now says a running connector picks up the change at a daemon restart (`rig daemon stop`, then `rig daemon start`). It also says not to use `rig slack disable` then `rig slack enable` for it, because enabling marks alerts still waiting to be posted as already seen. The old hint offered both as equal choices.

This matches the Slack setup guide and the known-issue line. Live reload is not part of this change.
2026-10-26 00:01:07 -07:00
Mike Schwarz f44bf98af3 fix(cli): a queue verb outside a managed seat names OPENRIG_SESSION_NAME, not its ignored flag (#2074)
Eight `rig queue` verbs take the actor from the seat's own identity (`OPENRIG_SESSION_NAME`) and ignore their deprecated `--actor`, `--from`, `--destination` or `--sender` flag. When `OPENRIG_SESSION_NAME` was unset, they said "--<flag> is required", which sent an agent to a flag that does nothing. They now say `OPENRIG_SESSION_NAME` is not set, give an example of setting it, and say the flag is ignored.

The two flags that are honoured, `create --source` and `whoami --session`, keep their message. How an actor is identified doesn't change.
2026-10-25 23:59:04 -07:00
Nash Aschenbath 2a8d353380 fix(daemon): resolve launch profiles off the request thread (#2067)
## What a user gets

Applying a pod spec, launching a materialized member, or retrying a failed first start can still block the daemon while resolving profiles, even though preflight already uses a worker. These paths now await the existing profile resolver worker, so a slow skill scan or catalog read can leave the request thread available.

An apply reuses one lazy worker across members, without a scan cache: a later member still reads changes made by an earlier member's startup. Standalone launches and retries use a short-lived worker. Refs #1653, building on the preflight work in #1759 and #1873.

Worker rejections become member configuration failures. When every member fails, apply follows its existing service rollback and generation restoration. A materialized member returns a failed result; first-start retry refuses before changing retained seat/session state.

## How you verified it

Source: `1adc689a5797e23e4a4b0447c3ca9000e069b893`, based on `4e4cba36f9f454e927e9bd508f299bd09cc68886`.

- Three launch-path regressions failed against the original instantiator at the health/FIFO assertion. The test requires the FIFO to remain blocked when HTTP health responds, then checks the selected skill reaches projection. It also checks fresh reads and dependency order between members.
- Four worker-fault cases failed before the rejection handling: apply skipped service rollback, while materialized launch and retry threw. They now verify rollback, restoration of the replaced generation, and preservation of retained seat/session state. The complete worker file passes all 16 tests.
- Native Linux verification passed `npm run lint` and all 91 tests in `profile-resolution-worker.test.ts`, `spec-library-starters.test.ts`, `claude-activity-hook-delivery.test.ts`, and `rig-added-seat-culture.test.ts` (four files, no failures or skips). The tested staged tree matches this commit: `67bab58ce12b7019a300dadbe81b7dc5cf3f8d74`.
- The committed native Linux checkout passed `npm run build`, `npm run test:repo`, and the host-mode `up-relaunch-restores-a-two-seat-team.yaml` scenario (1/1). The scenario starts two stub members, stops them, and starts them again with the expected recorded membership and state.
- [Final PR CI](https://github.com/mvschwarz/openrig/actions/runs/40276062989) is still running. Its results will be added when the complete package suites finish.
- The staged portability report found no matching machine-specific values; the PR portability job also passed.

An initial full daemon run from a Windows-mounted WSL checkout was stopped after a file-mode mismatch and two timeout failures. The mode mismatch and preflight timeout reproduced with the base production code; the culture test passed individually on the base and initial async implementation. The first host-scenario attempt from that checkout also exceeded daemon startup readiness before reaching any scenario assertion; it passed after moving the same source to native Linux. The native Linux tests above use the existing assertions and deadlines. The interrupted run is not counted as a complete suite pass.

## Anything you were unsure about

The responsiveness regression uses the real instantiator and SQLite with a simulated runtime. The scenario DSL has no concurrent health assertion or filesystem gate, so the existing worker-test FIFO fixture exercises the blocking condition directly; the separate two-seat scenario checks CLI/daemon/tmux lifecycle plumbing. Native Claude Code/Codex behavior and other synchronous startup work are not established by these tests.

Worker creation and copying the resolution context add some overhead. The existing deleted-process-cwd fallback remains synchronous. No whole-daemon latency benchmark is claimed.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
  * Health requests remain responsive while profiles are being resolved during member launches, materialized launches, and first-start retries.
  * Profile changes made during earlier members’ startup are picked up when resolving later members.
* **Bug Fixes**
  * Profile resolution failures are reported through the existing launch and retry failure handling, without launching affected members or leaving altered rig or session state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-25 23:47:00 -07:00
Mike Schwarz 3cb1d46e1f docs(cli): the CLI reference lists the account and permission choices (#2073)
The CLI reference now lists the flags these commands actually take. `rig provider precheck` and `rig provider switch` gain `--token-file` and `--stored-login`, and `rig seat set-permissions` gains `--rules`, `--codex-permissions` and `--codex-profile`. None is marked required any more, because each command takes one of several.

The reference was regenerated at main by the docs-desk generator, not hand-edited. It changed exactly these three rows and the header's source commit and counts. `--token-file`, `--stored-login` and `--rules` shipped in 0.7.2; `--codex-permissions` and `--codex-profile` are main only.
2026-10-25 23:37:14 -07:00
Mike Schwarz 128f04d15c docs: the capability map teaches 0.7.2 on main (#2071)
Main's capability map now teaches 0.7.2, the same text the published v0.7.2 map carries. Before, main still read v0.7.1, because 0.7.2's map was written on the release line only.

The 0.7.2 map commit is ported unchanged. Two lines that main's default-person change made wrong are corrected: the last escalation step goes to the default person, or the only one registered, and reaches nobody when several are registered with no default. A workflow's human step points to `rig gateway default <id>`, with `workspace.operator_seat_name` still read first.
2026-10-25 22:58:39 -07:00
Mike Schwarz 34fdcb8054 fix(terminal): a window gets its own sizing back after the web terminal and capture's resize hint (#2065)
After someone watches a seat in the web terminal and closes it, the seat's window gets its normal size back in Herdr, `tmux attach` or the operator's own terminal. Before, it stayed at the browser's size, so a native client saw narrow text with a dotted background around it.

The broker pins and gives back the window by its id (`@N`). It records the prior `window-size` and the brokers holding the pin on the window, in `@openrig-window-size-prior`. The last live holder gives the window back. A pin whose holders are gone, after a daemon restart or crash, is given back by the next broker, and a window that no longer lists this broker, such as one a restarted tmux server reused, is left alone. Every way the broker lets go gives the sizing back before the registry drops it. `rig capture`'s small-pane hint now also says how to unset the window's own size.

Not covered: a browser that closes while the daemon is down (the window is repaired on its next visit, or by hand), and two brokers updating the record at the same moment.
2026-10-25 22:32:16 -07:00
Mike Schwarz 44e10a1e5b fix(daemon): report an empty registry accurately in digest (#2066)
On a fresh install with no one registered, the notice digest's skip reason now says "no person is registered" instead of "the human registry can't be read".

The registry loader reports "no fragments and nothing projected" as a refusal marked `noHumans`. The flush now checks that before treating the result as unreadable. A registry that genuinely can't be read is still reported as such, and dispatch is unchanged. The test uses a real fresh home through the read-only loader.

Follows #2063 (thanks to @Coder8124 for the skip reasons). Part of #800.
2026-10-25 21:41:09 -07:00
Mike Schwarz f554c32edc docs(cli): say which scope commands refuse a missing --workspace tree (#2064)
The shared `--workspace` help now says what the commands do since #1093: `slice create` and `approve` refuse when the named workspace's tree is missing, and `mission create` refuses a path that isn't an existing directory and otherwise creates the missions tree inside it. Read commands keep the fallback to the configured root.

Text only; no behaviour change.
2026-10-25 21:08:56 -07:00
Mike Schwarz c866a161c0 feat(attention): the Feed shows whose queue, and terminal acts carry the primary user's authority with the person asked (#2006)
The person at the terminal, the primary user, can look at a colleague's queue in the TUI and act on what that colleague was asked. The record keeps both identities: who acted and who was asked. Viewing someone's queue is never acting as them.

- `whose <person|all|default>` chooses whose asks the Feed shows, and its first line names whose queue it is and who acts. With a chosen default person the TUI opens on that person's Feed; otherwise it opens on the topology, as before.
- Acts on someone else's ask (approve, answer, or resolve a row parked on them) are recorded as the primary user, with the person asked in the audit notes and the transition ("by mike@external, the primary user at the terminal, on sarah@external's ask").
- A registered person without the primary user's authority still can't settle another person's ask, pending or parked; that's refused with answer_not_recorded and nothing changes.
- The authority is honored only on a Mission Control request with no seat identity header, which is how the TUI calls the daemon. Seats and the CLI keep their own identity. This is the existing trusted-network distinction, not authentication.
- A registered shared channel never acts: with no one registered, a terminal act on a channel's ask is recorded as human@host with the operator's words; a channel's own answer, approve, deny or resolve is refused before any change; and a customer's message in the thread is never taken as an existing answer.
- Otherwise, with no one registered, the Feed and its acts are as before.

The primary user is #1992's default person, or the only registered person; with several people and none chosen, acts record human@host. `GET /api/attention?view=` filters the Feed's person items and returns a `perspective` block, and delivered updates follow the same view.

On main after #1992, #2061 and #1998, resolveParked keeps the typed external:, fold: and auth: gates and folds #2061's asked-person check into asActor, which refuses another registered person the same way and still lets the primary user act. A typed gate has no asked person, so a primary-user resolve keeps the gate's note.

Guidance is in docs/reference/gateway-participants.md ("In the TUI: whose queue, and who acts"), with upgrade and rollback lines. No new schema, dependency, setting or route.
2026-10-25 21:07:05 -07:00
Coder8124 4e4cba36f9 fix(daemon): say why the delivery digest sent nothing (#2063)
When the notice digest sends nothing, the watchdog's skip reason now says why instead of "nothing to flush":

- the person registry can't be read;
- no person is registered;
- every item is held for quiet hours;
- notices wait because several people are registered and none is the default;
- dispatch failed before anything was enqueued.

`runDeliveryDigestFlush` returns an optional `idleReason` only when it dispatched nothing, and the watchdog policy uses it as the skip reason. Dispatch itself is unchanged. A real-SQLite test covers each reason.

Part of #800.
2026-10-25 20:47:22 -07:00
Coder8124 206a88e1ba fix(cli): create a named workspace's first mission there, not in the global root (#1093)
`rig scope --workspace <proj> mission create <name>` and `slice create` no longer fall back to the global `workspace.slices_root` when the named workspace can't be used. They stop with an error and write nothing.

- `mission create` with a workspace that doesn't exist, or a workspace path that is a file, is refused, so a mistyped `--workspace` (or `OPENRIG_WORK_ROOT`) doesn't start a new tree there. The configured root isn't used.
- `slice create` refuses the fallback when the named workspace has no missions tree, because the mission it adds to must be in the tree you named.
- Unchanged from #1430: inside an existing workspace, a project's first mission goes to its declared `missions.root`, else `<proj>/missions/`, created if missing, with the same advisories, and reads resolve the declared root the same way.

#1430's tests that expected `mission create` to create a missing named workspace now expect the refusal, as does the workspace-is-a-file case; a new case covers `slice create`.

Refs #70.
2026-10-25 20:41:05 -07:00
Mike Schwarz 4dec723d19 feat(gateway): attributed shared-channel conversations (#1998)
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat without registering each of them.

- In: an unregistered Slack sender is admitted only in a registered shared channel's own Slack channel, as that channel participant (`acme-customers@external`), including with no registered person at all. The seat's row carries a labelled block, last after the message: the channel, its organization and groups, who actually wrote (marked as not a registered person), and the channel's effective policy. It's tagged `participant:<id>`, `shared-channel` and `slack-sender:<user>`. A registered person writing there stays themselves.
- Correspondence by construction: a message admitted as a channel never becomes an answer, never closes or resumes an ask (even one addressed to the channel), and a click there records nothing.
- Back: a registered shared channel is a valid queue destination. A row to it posts as a notice, whatever its intent. An update with `--reply-to` lands in the customer's thread, and so does a later one; posts to a channel mention nobody. An ask to a channel is accepted with an advisory that nothing there will answer or close it, and its questions show as a list without buttons.

One participant per vetted channel, from #1992's participant file; no customer is registered and no second store is added. Admission fails closed: an unreadable participant file admits no channel senders, and a broken person registry still refuses everyone. Policy is readable context in the row; nothing enforces it.

Tests cover admission, channel-only setups, never settling anything, and the way back through the real Slack wire with a fake transport.
2026-10-25 20:29:54 -07:00
Mike Schwarz f47dfa2c54 fix(config): accept dotted host subscription IDs (#2062)
A registered host ID containing dots, such as `mac.local`, can now be enabled with `rig config set feed.subscriptions.mac.local.enabled true`. The host registry already accepted such IDs, but both subscription parsers rejected the key and the daemon's subscription reader ignored it.

Both parsers and the reader now accept dots in the host ID. Storage already treats the captured ID as one literal JSON property, so there's no migration, escaping scheme or registry change. Reserved toggles, boolean-only values and the other unknown-setting checks are unchanged.

Refs #2057; thanks to @AdamWhitehurst for the report and the config-side reproduction.

Tests cover both writers, persistence as one literal host key, read and reset, parser parity, and the real attention aggregator with mocked HTTP, including disabled-host filtering. No new refusal or error code.
2026-10-25 20:07:29 -07:00
Mike Schwarzanddev-driver d2ab06977a fix(queue): resolve parks addressed to registered people (#2061)
* Prepare registered-person park resolution for remote verification

* test(queue): expect conflict when a park is already resolved

* test(queue): preserve asked-person semantics for terminal resolve

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-10-25 19:59:05 -07:00
01d2440f8b fix(cli): report local send connection loss as unconfirmed (#2060)
* fix(cli): report local send connection loss as unconfirmed

* test(daemon): stabilize compaction retrigger fixture clock

---------

Co-authored-by: orch-lead <orch-lead@core-health>
Co-authored-by: dev-qa <dev-qa@core-health>
2026-10-25 19:20:34 -07:00
Brad Vincent c21e4d7994 feat: target saved seat settings by node ID (#2051) 2026-10-25 18:57:24 -07:00
4e3ba99a4f feat(gateway): several people, shared channels, organizations, groups, readable policy and a default person (#1992)
* feat(gateway): several people, shared channels, organizations, groups, readable policy and a default person

The gateway can now register several people and shared-channel participants
(one vetted Slack channel is one participant at <id>@external), group them into
organizations and groups, give each readable policy text, and name a default
person.

Storage keeps rollback safe. Person fragments keep the closed key set a v0.7.0
daemon reads, so an older daemon still loads every person. Everything new lives
in gateway/participants.yaml, which v0.7.0 and v0.7.1 never read. A broken side
file never disables the person registry; a person removed by hand leaves a
warning, not a broken file.

Policy is text agents read, shown organization first, then each group, then the
participant's own, each labelled with its source. Nothing enforces it or derives
it from names.

The default person serves needs that name nobody: escalations, the notice
digest, Mission Control's my-queue, the workflow fallback and pairing approval.
It's the chosen person, else the only one, else none with the reason, never the
first file by id. workspace.operator_seat_name keeps its existing meaning.

Verbs: `human add` accepts several people and takes --organization, --group,
--policy/--policy-file and --default; `human show` adds the placement; `human
set` takes organization, groups and policy; `human remove` also clears the
person's placement. New: `channel add|remove`, `org set`, `group set`,
`participants` and `default`.

A second registered person's Slack reply, typed or clicked, never answers,
closes or resumes another person's pending or parked ask. Main already guarded
this at two independent places; the new test pins both.

docs/reference/gateway-participants.md covers the commands, the default, upgrade
(nothing to do) and what a rollback to 0.7.1 or 0.7.0 changes. The
messaging-the-human skill points agents at `rig gateway participants`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(gateway): a person can't take a shared channel's id; upgrade note for several people

People and shared channels share one address space (<id>@external). Adding a
channel already refused a person's id; adding a person now refuses a shared
channel's id too, in the registry's own add path and before any write, so the
participant file, its default and the channel stay intact. Before, the add
succeeded and the participant file stopped reading: the chosen default, every
placement and policy were lost, and every repair refused.

`human remove` now deletes only a person's entry from the participant file, and
`channel remove` only a channel's, so neither can remove the other kind.

The upgrade note now covers installs that already had several people: choose a
default with `rig gateway default <id>`; until then escalations report
undeliverable:no-default-person and the notice digest waits.

The ordering tests now expect the list sorted by id, people and channels
together, and pick the person by id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: review50-r3 <review50-r3@openrig-build>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-25 18:51:28 -07:00
Mike Schwarzandorch-lead b71cd5583d docs(roadmap): record 0.7.2 release and active 0.7.3 themes (#2059)
Co-authored-by: orch-lead <orch-lead@openrig-build>
2026-10-25 17:01:31 -07:00
Mike Schwarz 350aeb18dc docs(changelog): carry corrected published 0.7.2 notes (#2058)
Carry the corrected published 0.7.2 notes into the changelog on main, preserving contributor credits and verification limits. Only documentation changes; no tag, archive, npm or Release edit.
2026-10-25 16:28:12 -07:00
Mike Schwarz bd617c4b0a docs(herdr): name the two known cases where Herdr reads a waiting seat as idle (#2056)
Clarify when Herdr may show a waiting seat as idle: a wrapped Claude footer or a visible inner tmux status line. Keep OpenRig's view and Feed as the route to find waiting seats.

Documentation only; matching mirrored skills and generated digests. The supporting evidence uses replay and screenshot-derived frames. No native runtime fix, historical causal proof, or live settings change is claimed.
2026-10-25 16:10:19 -07:00
Mike Schwarz b47e280bd2 fix(seat): a runtime switch's handover to Claude gets startup's first-input protections (#2054)
A Claude runtime-switch successor uses the existing startup input-box wait and guarded staged-text Enter retries. Shared helpers keep the startup rules in one place; Codex successors retain their existing sequence. Confirmation still controls commit, with rollback and recovery unchanged.

Independent launch-sensitive source pair clear; exact-head hosted CI passed. Builder unit RED/GREEN and broader Linux A/B are attributed. The observed native long-handover cause remains unknown and this patch has not been shown to cure that case. Bounded observation adds waiting, and full staged matching uses the existing normalization. No new setting, refusal or repaste.
2026-10-25 16:10:03 -07:00
Mike Schwarz f6d012892c fix(tui): keep a registered remote host in the topology tree and list its rigs (#2052)
Registered remote hosts stay in the topology tree without a feed subscription. Drilling into an HTTP host lists its rigs and states, with host-qualified pointers for seats. Failed reads are distinguished from empty results, and local facts/actions are not presented as belonging to the remote host.

Reported by @AdamWhitehurst. Refs #2049. Local display-label item remains separate; this does not close the whole issue.

Verification: independent source review and required hosted CI. Builder Linux tests and two hermetic daemons exercise built hydrate/render, not an interactive TUI or a real second machine. SSH hosts remain listed but remote summary forwarding is HTTP-only. Remote TABLE retains small unused local health/queue reads; GRAPH may read unused local rig graphs, attached only to the local host and not rendered as remote. No remote seat actions are added.
2026-10-25 16:05:17 -07:00
Mike Schwarz ec11c43d22 fix(cli): honor skill loadout idle wait deadlines (#2024)
`rig skill loadout --seat X --apply --wait-for-idle 60000` can wait a minute instead of failing at the client's five-second default. Its request timeout is the requested idle interval plus five seconds; without a wait option it keeps the existing client default.

This is the unchanged two-file loadout delta, now based on main after #2020 merged. It uses that client's long-request path for deadlines of at least 300 seconds, sends one POST with the full original wait, and reports the daemon response unchanged. No retry, continuation, outbox, route or shared-helper change is added.

Validation: paired source review and eight required CI checks pass at40367426. Existing fake-clock real-client controls cover six-second responses for60-/600-second waits, transport selection, exactly one POST, the timeout margin and unchanged output; the actual-main restack's own patch is byte-identical. These checks do not establish a native agent reading the skill list.
2026-10-25 16:05:08 -07:00
Mike Schwarz 5a90bfcc24 fix(tmux): retain the pane terminal identity through scoped launch (#2043)
New scoped tmux panes keep tmux's own `TERM_PROGRAM` and `TERM_PROGRAM_VERSION` through the launch's initial environment reset. A managed Claude seat already forwards those names, but they were erased before that step, so Claude couldn't identify tmux and request the extended keys it uses for Shift+Enter.

The reset now keeps these two fields alongside `TERM`, taking the values from the newly spawned pane rather than the daemon or its configured baseline. It applies to fresh and successor panes that use the reset. No terminal option or session environment changes. On Linux, #2036 already joins a relative `WAYLAND_DISPLAY` selected through tmux `update-environment` to the seat's runtime directory.

Refs #2041; thanks to @AdamWhitehurst for the report and the environment evidence. The obsolete integration-test assertions and first-server fallback were fixed separately in #2047. This change addresses the terminal identity loss; the Linux Wayland statement is conditional on the selected display environment.

Tests cover present, empty and absent terminal values through the composed scoped reset and managed launch, including a conflicting daemon baseline. No new refusal or error code.
2026-10-25 15:36:07 -07:00
Mike Schwarz b69cda2404 fix(envelope): the reply hint for a person's message answers in its Slack thread (#2055)
Include the inbound item in external reply hints so an agent can reply in the originating thread. Existing thread resolution and update validation remain in use. Validation: author-reported focused envelope tests and TypeScript builds; all eight required hosted CI jobs passed at the admitted head. No live Slack round trip was exercised for this review.
2026-10-25 14:25:57 -07:00
Mike Schwarz 2e428ef8a2 feat(codex): retain per-seat permissions across managed continuity paths
Adds per-seat Codex permission envelopes and profile setters through the existing audited permission surface. Inherited runtime switches carry the envelope; an explicit target mode intentionally replaces it only when the switch commits. Profile checks resolve the target seat home.

Independent source pair clear; exact-head hosted CI passed. Native sandbox/approval enforcement is separate. Existing unreadable/dual stored-record handling remains; no envelope-plus-explicit-switch refusal.

The clearing helper tolerates only the intended missing-column fixture case; other initial read failures roll back the existing transaction. Focused tests cover both discrimination and switch state restoration.
2026-10-25 13:46:28 -07:00
Mike Schwarz 294e6cf118 fix(codex): retain managed config tables after Codex saves (#2046)
Keeps managing unchanged OpenRig config tables after a Codex save moves them, and preserves other tables printed between managed markers. Array-of-tables fragments project and replace their owned entries without repeating them.

Independent Claude/Codex source review retained across patch-identical restack; required hosted CI passed. Existing limits remain: moved array entries outside markers may duplicate, entries under owned array roots inside markers are replaced, and changed moved tables remain user-owned.
2026-10-25 13:01:37 -07:00
Dery Ferdika 9166d06140 fix(scripts): handle Windows path separators in skill catalog discovery (#2050)
The first-run reference checker now discovers shipped `SKILL.md` files on Windows. Its walker uses native `path.join`, so testing for a literal `/SKILL.md` suffix returned an empty catalog on Windows; `path.basename(p) === "SKILL.md"` handles the native separator and matches the sibling scripts' existing idiom.

One line changes. Reference parsing, skill names and validation rules stay the same. The independent source pair is clear and all eight required CI checks pass. The contributor reports a native Windows before/after run:53skills discovered,489references checked,0failures; that run was not independently repeated by the desk.
2026-10-25 12:56:24 -07:00
Mike Schwarz 1a620fc505 fix(codex): keep Codex's own trust tables in OpenRig's hook block, and don't re-add hook groups Codex moved (#2035)
What a person gets

After this, a person's own Codex hook trust survives OpenRig's next seat launch, so Codex stops asking them to trust the same hooks again every launch. OpenRig never adds a second set of its activity hooks either.

Before:
- **Codex 0.164.0 rewrites all of `[hooks.state]` when it saves trust.** That's its "Trust all", a `ConfigEdit::SetPath` on `hooks.state`. `toml_edit` then prints the rewritten table after the last hook group, which is inside OpenRig's `# BEGIN/# END OPENRIG MANAGED ACTIVITY HOOKS` block, ahead of the END comment. The END comment was the prefix of OpenRig's first trust table, and it moves with that table.
- **OpenRig's next launch replaced everything between the markers.** That deleted the person's newly trusted entries, so the prompt came back on every launch. Turning OpenRig's hooks off deleted them the same way.
- **When a save left the markers apart from OpenRig's groups,** OpenRig appended a second block, and the relay ran twice per event.

Reported by @borfast in #17. #2028 fixed the other half of that report, the trust key's group position.

If Codex already shows OpenRig's hook twice, from a config the old code doubled, remove the extra `[[hooks.<event>]]` groups that run OpenRig's relay, once. This change stops new copies; it doesn't remove old ones.

What changed

- **OpenRig's groups are read as TOML.** The config is split at its real table headers, never at text inside a string or an array, and each header's key is decoded in any spelling, such as `[[hooks."Stop"]]`. A group is OpenRig's when it's for one of OpenRig's four events and runs nothing but OpenRig's relay. A person's own group for the same event, inside the markers or not, is theirs.
- **Inside the block:** the activity-hook block rewrite and the strip that turns hooks off now keep every section between the markers that isn't one of OpenRig's groups, whole, with the comments just above it. Codex's `[hooks.state]` tables stay.
- **Groups without markers:** when the markers are gone but groups already run OpenRig's shipped relay, those groups are OpenRig's where they sit.
  - Only the `command` line of each one's own handler is pointed at the current relay, so an upgrade still works.
  - No second set is appended.
  - Their trust is keyed by their own positions.
- **A check before writing:** the inline writers confirm that only OpenRig's groups and the `[features].hooks` flag changed and that the file still parses. Otherwise the config is left as it is, with a line in the daemon log. A config that doesn't parse is left as it is too.
- **Unchanged:** a person's own hooks, their order, and every other table.

Tests

`codex-hook-trust.test.ts`, "#17":
- **The fixture reproduces Codex's save from the inspected writer.** It is source-derived, not a captured native save. It takes OpenRig's launch of a config with a person's own hooks, then applies the 0.164.0 write: `[hooks.state]` and every entry, sorted, with literal-quoted keys and values, printed after the last hook group, with the END comment travelling with OpenRig's first trust table. A first case checks the person's trust really is inside the markers.
- **The next launch keeps:**
  - the person's trust;
  - their groups and order;
  - exactly one set of OpenRig's groups, with correct trust.
  - It's also idempotent.
- **Turning hooks off** keeps the person's trust.
- **With the markers gone,** with the current relay or an earlier install's relay, no second set is added, and the commands point at the current relay.

**Read as TOML** (`codex-hook-trust.test.ts`, "OpenRig's groups are found by what they are, not by their text"):
- a relay command example inside a person's multi-line instructions stays exact;
- a person's group for another event that runs the relay isn't repointed;
- OpenRig's group written with a quoted event key is removed when hooks are turned off, and replaced rather than kept beside the new one;
- a kept section whose multi-line string holds a `[[hooks.Stop]]` example stays whole, and the file stays readable;
- a person's own PermissionRequest group inside the markers is kept on a launch and when hooks are turned off;
- a config that doesn't parse is left byte-identical.

With #2034's source, the four behaviour cases above them fail. With this PR's first revision, the seven new cases fail.
2026-10-25 12:23:49 -07:00
Mike Schwarz 2f3ca5fb66 perf(views): skip unused blocked pickup history
Execution views read transition history for blocked Q1 lanes and Q5 rows before pickup immediately returns `parked`. Skip that unused read at those two callers. Live history stays fresh, and the separate armed-wake lookup and park outputs remain unchanged. Required-read errors still propagate; failures solely in the omitted read no longer fail the view.

Validation: 140 focused owner tests and daemon typecheck passed; independent QA passed 124 existing tests plus one adverse case, and both source reviewers approved. One contained same-snapshot product comparison reduced history reads from 246 to 4 and post-cursor work from 527 to 208 ms, preserving the full response except build identity. This is a local result, not a released-version or historical-incident cure claim.
2026-10-25 12:05:39 -07:00
Mike Schwarz 20009a9e0d fix(resume): Claude's Resume session picker waits for a person, and sends refuse there
When Claude Code 2.1.309 shows its "Resume session" picker instead of resuming an exact conversation (a bare `--resume`, or a name that matches no title or several), OpenRig no longer reads the seat as resumed and ready. Before, startup input or an ordinary send could land in the picker's search, and Enter could resume the wrong conversation. 0.7.1 behaved the same.

- The native resume probe and `checkReady` read the picker as `attention_required` / `claude_resume_selection_prompt`, in tall and default panes.
- Startup reports `attention_required` at once instead of waiting out its readiness timeout; the same attention set also covers the numbered and two-choice choosers.
- A plain send at the picker returns `target_needs_input` and types nothing.

The picker is recognised by its "Resume session" heading followed directly by its framed search box. It stops counting only once Claude's framed input box or a column-0 reply is drawn after it, so an old picker above a resumed session reads as resumed, and a card title that mentions "Claude Code v…" can't dismiss a live picker. The check is skipped for Codex and Pi panes. An exact id Claude can't find still reads as `no_conversation_found`.

Tests use the real 2.1.309 picker frame. Cost against main: source +36/-1, tests +126 (including a 4-line fixture). No new error code; the existing `target_needs_input` refusal now also covers this picker.
2026-10-25 12:02:44 -07:00
Mike Schwarz b0e5410bc0 fix(launch): the first seat on a new tmux server gets the scoped environment too (#2047)
The first seat started by rig up on a new tmux server now reads the same scoped launch options as later seats. Reuse the existing server-start helper when the initial default-shell read is unavailable, then retry that read. Existing servers and the existing fallback/warning remain.

The Linux environment test now checks the scoped values instead of expecting inherited daemon values, and a private-tmux adapter case covers the first-seat path. The existing empty-server helper keeps an empty server available, as on other startup paths.

Validation: independent paired source review, builder Linux before/after controls and focused checks, required exact-head CI8/8. The Linux /proc integration test is skipped on macOS CI; no new Linux CI job or native provider acceptance is claimed.
2026-10-25 11:52:09 -07:00
Mike Schwarz 322fa11d91 fix(cli): --wait-for-idle longer than 300s uses one request (#2020)
Long --wait-for-idle sends no longer fail at Node fetch’s 300-second header limit. Requests with a budget of at least 300 seconds use node:http/https as one request, preserving the daemon’s deadline and occupant binding. Shorter requests keep fetch. A deadline remains a timeout even during body consumption; a dropped response body stays unreadable/unknown rather than claiming the message was not sent.

The HTTP test guard is installed only in the CLI suite; shared fetch guarding and cleanup remain. No dependency, setting or daemon route. The limited transport accepts string bodies and uses direct connections without global proxy agents.

Validation: prior independent paired behavior review, independent harness-only delta review, builder real 400-second wait and focused regressions, and required exact-head CI 8/8. This does not claim a native runtime-switch test.
2026-10-25 11:35:05 -07:00
Mike Schwarz f2fce0ae6b perf(views): yield execution candidate reads (#2039)
Large execution views collect candidate rows in one synchronous SQLite call, making health checks and unrelated requests wait for seconds. Read WAL candidates through a request-owned readonly cursor that yields between slices, and serialize complete execution builds per projector. Transactions, memory and non-WAL databases retain synchronous main-handle reads.

SQL, bindings, row order and complete results are preserved. Candidates use one committed snapshot; later history reads can observe writes committed during yields. Native steps can still block, and queued execution requests can exceed the existing client deadline.

Validation: 138 focused tests, two independent transaction/cleanup adverses, and a matched source-built full-request case confirming complete-result parity apart from build identity and health-handler progress during the cursor read. This does not establish an overall request-time speedup.
2026-10-25 11:34:35 -07:00
Mike Schwarz 5ef718c4d5 fix(codex): keep resume identity ahead of writable paths (#2038)
Codex resume and legacy restore now put the conversation id before the queue-state and team-workspace `--add-dir` arguments. OpenRig reads a Codex's identity from its unquoted process line, so a path containing a space there could be mistaken for the conversation id, leaving continuity unverified and later delivery refused.

The shared builder (`buildCodexResumeCore`) moves the already-resolved directory segment after the id, keeping directory order, permission choices and resolver calls. Callers without that segment produce the same commands. Process lines in the old shape are still read, so a seat already running that way is identified as before. Together with #2037, which did the same for an in-place restart, every Codex resume OpenRig builds is `resume <id> --add-dir …`.

Tests: a spaced shared-docs root and a spaced legacy team workspace each read the expected conversation id, through the real adapters' commands and the production process-line parser. Cost: 3 files +32/-6. No new refusal or error code.
2026-10-25 11:07:36 -07:00
Mike Schwarz a16735a916 fix(codex): find trust tables Codex saved with single-quoted keys instead of adding duplicates (#2034)
A Codex seat launch now finds the project and hook trust tables Codex already saved in `~/.codex/config.toml`, whatever quoting they use, and updates them in place. It never appends a second table for the same key. Reported by @borfast in #2029.

Codex saves its tables with TOML literal (single-quoted) keys, such as `[projects.'/home/me/app']` or `[hooks.state.'…/config.toml:session_start:0:0']`. OpenRig looked only for the double-quoted spelling, missed them and appended a duplicate. Codex then refused to start, and so did every other Codex session on the machine, until the duplicates were removed by hand.

If Codex already refuses to start with "trying to redefine an already defined table", remove the extra double-quoted table it names, once. The trust writers leave a config Codex can't read exactly as it is, so that one removal is the whole repair.

What changed:
- The four trust writers (`upsertCodexProjectTrust`, `upsertCodexHookTrust`, `upsertCodexHookDisabled`, `rekeyCodexHookStateText`) and the owned plugin's content-approval check (`planOwnedCodexContentApproval`) find a table by its parsed key, read with the adapter's existing `smol-toml`, not by its text. Bare, double-quoted and single-quoted keys, spaces around the dots and a trailing comment all match.
- A header-shaped line counts only when it really opens a table in the document: swapped for a probe header the document doesn't already have, the parsed document gains that table. A table example inside a multi-line string, such as a person's `developer_instructions`, is left exactly as it is, and the real table is written.
- When the document already defines the key another way (an inline table or dotted keys), the writer leaves the file as it is; Codex's own trust prompt stays the fallback. The trust writers also leave a config that doesn't parse as it is, since appending can't make it readable.
- The helpers live in `domain/toml-tables.ts`, shared by the adapter and the owned-plugin check. A person's other tables and values are unchanged.

Not in this change: the rest of #17's repeated trust prompt (Codex re-saving `[hooks.state]` inside OpenRig's marked hook block) is #2035.

Tests cover single-quoted, inline and multi-line-example cases through the writers, `deliverStartup` and `ensureCodexActivityHooks`, the owned plugin's renewal, an unreadable config, and a probe-named key. Cost against main: source +77/-12 (the adapter, the owned-plugin check and the new `toml-tables.ts`), tests +134/-1. No new refusal or error code.
2026-10-25 10:48:38 -07:00
Mike Schwarz 4222e60804 fix(launch): Linux seats get the runtime directory, with a session bus address that can't connect (#2036)
On Linux, when the daemon has an absolute `XDG_RUNTIME_DIR`, a seat now gets it, together with `DBUS_SESSION_BUS_ADDRESS=disabled:`. Tools whose sockets live in the runtime directory (an MCP server's daemon, gpg-agent, rootless Podman) work in seats without per-tool setup, and the seat's D-Bus clients don't discover or autolaunch the session bus on their own. Refs #2030; reported by @borfast.

`disabled:` was chosen from the libraries' sources: libdbus 1.16.2, GLib 2.88.0 and sd-bus 259.5 fall back to the runtime-directory bus, or autolaunch, only when the address is unset, and refuse an unknown transport before opening a socket. A Linux control with a throwaway bus confirmed it. This is default hygiene, not isolation.

It applies to the scoped tmux launch (fresh launch, restore and handover successors) and the managed Claude launch. A seat's own `XDG_RUNTIME_DIR` (from `env:`, a one-off name or tmux `update-environment`) leaves both names as before, a seat's own address is kept, and an empty address is filled. Unchanged: `terminal.inherit_tmux_environment` on, macOS, and a daemon started without `XDG_RUNTIME_DIR`. Pi and OMP agent processes keep their fixed list.

Changed behavior, accepted deliberately:
- `systemctl --user` connects to `$XDG_RUNTIME_DIR/systemd/private` regardless of the bus address, so a seat can reach the person's user service manager.
- A shell startup file that sets only `XDG_RUNTIME_DIR` used to let a classic seat's clients find the bus. Now the seat keeps `disabled:`. Set the address in the seat's `env:` to keep the bus.

Known limit: Codex starts its stdio MCP servers with a fixed list of variables, so a Codex MCP entry still needs `env_vars = ["XDG_RUNTIME_DIR"]` in `config.toml`. Codex hooks and shell commands, and Claude Code's MCP servers and hooks, get it with no setup.

Cost: source +56/-8 (`seat-runtime-dir.ts` new, `tmux.ts`, `startup.ts`, `claude-managed-launch.ts`), tests +114/-3, docs +10/-1. No new setting, refusal or dependency.
2026-10-25 10:33:06 -07:00
Mike Schwarz c241fbb45f fix(codex): an in-place restart's relaunch puts the running --add-dirs after the conversation id (#2037)
`rig seat restart <codex-seat> --keep-conversation` reads complete when the same conversation resumed, and the seat can be messaged afterwards, when a directory Codex was given has a space in its path, such as a working directory named `my repo`.

Before, in that case, the restart reported incomplete ("no single Codex process resuming this conversation was observed"), `reconcile` read continuity unverified, and an ordinary `rig send` was refused. The same conversation was running the whole time.

The cause: the relaunch reused the running Codex's own `--add-dir`s, git dirs included, and placed them between `resume` and the conversation id. OpenRig identifies a Codex by its process line, which joins argv with spaces (both `/proc` and `ps`), so that line named `repo/.git` where the id belongs. A shell or `node` wrapper isn't involved; the proof follows the chain from the native Codex through `node` and `sh` to the pane.

The change, in `codex-runtime-adapter.ts`: for an in-place restart, the running `--add-dir`s are reused unchanged and now follow the conversation id. Nothing is resolved again, and the running seat folder isn't added twice.

Not changed here: an ordinary resume still puts its queue-state `--add-dir`, and the restore path its team-workspace argument, before the id. Those are read wrongly only when that path has a space, and they're a separate fix.

Tests: the real process-line parser and pane observer read the id from a relaunch whose working directory has a space. Cost: source +4/-3, tests +29/-1. No new refusal or error code.
2026-10-25 10:23:32 -07:00
Mike Schwarz 00e2c8066d perf(views): skip unused post-claim counts for historical rows (#2031)
The execution view counted post-claim transitions for every candidate, including blocked and closed rows whose pickup logic never reads the count. Restrict this caller’s correlated count to pending and in-progress rows. Preserve historical rows and bodies, project/mission filtering, park/wake output, and the live bookkeeping-only fallback in the same SELECT.

Validation: 122 affected tests and daemon typecheck pass. Independent Codex and Claude source reviews pass. QA independently repeated all 122 tests and checked full-document parity through the real views route for project filtering and claimed-pending pickup; both adverse cases passed. The recurring severe-stall investigation remains open.

The parity test sets aside the free-disk reading (`q6_parallelism.df_margin.available_kib`), which is taken live at read time and can differ between the two documents it compares; every other field is still compared.
2026-10-25 10:05:40 -07:00
Mike Schwarz 5238c6f461 fix(queue): a dead seat's sweep finding names the rule that chose its recovery seat (#2033)
When a seat's session is confirmed missing, the stuck sweep sends its finding to a live recovery seat. The finding's reason now says which rule chose that seat, so whoever receives it knows why it landed on them, and which setting to change if it shouldn't have:

- "... so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)"
- "... goes to approver@r, the operator seat (workspace.operator_seat_name)"
- "... goes to sender@r, the row's creator"

Before, it named only the destination. The recovery seat is picked over the same candidates, in the same order, with the same predicate, so routing doesn't change. The sentence for "no live recovery seat is available" is unchanged.

Tests pin the rule name for each of the three outcomes; the operator-seat case only checked the destination before. Cost: source +12/-3 in `queue-stuck-sweep.ts`, tests +5/-3. No new refusal or error code.
2026-10-25 09:38:32 -07:00
Mike Schwarz 3af5abd33a fix(transport): recognize Pi input and editor dialogs (#2026)
An ordinary `rig send` into a Pi v1.4.0 extension input or editor dialog now returns `target_needs_input` and types nothing. Before, the message could become the dialog's answer, because Enter submits it. The deliberate, reasoned and audited input route is unchanged.

This covers default 80-column panes, where the help row wraps over up to three rows, and tall editors whose opening rule is outside the transport's 20-line capture. An editor dialog is recognized by its bottom rule, the complete four-action help with exactly one blank row on each side (Pi's `ExtensionEditorComponent` draws a spacer before and after it), and the dialog's closing border. So a draft in Pi's own main editor that quotes the help text still sends. Inputs keep the field, spacer and nearby opening-rule checks. #2015's later-composer and fence veto and its six-row footer limit still apply.

Pi renderer source: earendil-works/pi v1.4.0 at `4a080252f06caf63a525d7873c5d94d3c23887e0`. The fixtures reconstruct that renderer; they aren't native captures.

Limits: this stays a bounded screen heuristic. Clipped or incomplete help, more than three wrapped rows and arbitrary custom extension UIs keep their prior classification. A draft that copies the dialog's complete chrome, spacers included, still reads as a dialog: the sender is told and nothing is typed.

Cost: source +29/-5 in `session-transport.ts`, tests +105. No new refusal, error code or native runtime change.
2026-10-25 09:35:57 -07:00
Mike Schwarz ef0350a0fb fix(codex): a pod-less Codex seat's restore keeps its git dirs writable, so it can still commit (#2023)
A Codex seat without a pod restores through `CodexResumeAdapter` (`rig up --existing`, or a restore after a reboot), and that path added none of the git metadata `--add-dir`s #2000 gave fresh, resume and fork launches. So a default workspace-write seat restored that way couldn't write its worktree's `.git`, and commits failed. It now gets the same git dirs from the same resolver, then its seat folder, after the conversation id, so the `ps` proofs still read the id. They're added only on the plain workspace-write floor, since a named profile isn't checked for read-only on this path and Codex refuses a writable root under a read-only sandbox. Seats in a pod restore through the launch adapter, which #2000 already covers.

Tests use real temporary repositories: an ordinary repository's restore adds `<cwd>/.git`, and a linked worktree's adds its worktree and common git dirs, never the `.git` file.
2026-10-25 09:26:21 -07:00
Mike Schwarz 80fc3b3fb5 feat: per-member Claude tool rules and audited updates (#1914)
Claude members that share a project folder can keep distinct, durable tool rules. Declare `claude_permissions.allow`/`deny` in the spec, or replace a retained seat's rules with `rig seat set-permissions <seat> --rules <json-file> --reason <why>`. The update is audited and takes effect at the next managed launch or resume. It doesn't restart the seat or change its permission mode.

Migration 139 stores one current record per node. Fresh startup, resume, legacy restore and same-seat handover read it, and a fork copies the source's current rules independently. Rules compose into the existing single `--settings`, keeping defaults, hooks and compaction settings, and are never written into the shared project's Claude settings. Status and export show the current plan. Stored Claude rules stay dormant while a seat runs another runtime and apply again when it returns to Claude.

A stored record that's invalid or unreadable refuses that seat's launch, fork or export with a repair message, rather than launching without its denies; the fork API reports `permission_rules_unavailable` (409) before creating a child.

Native check on this head (Claude 2.1.309): two members in the same folder get their own rules (one reads and writes a file the other is denied), a user-wide allow doesn't override a member deny, managed policy wins, and a setter change applies at the next launch, not in a live process. Not proven: that handover and restore keep the same conversation (the rules are carried in the launch arguments), and deny enforcement under bypass mode, which isn't claimed.

Limits: allow lists don't withdraw inherited grants; these are native tool rules, not OS or credential isolation. The pre-feature reader `75c1f045` opens the upgraded database but leaves the rules out of launch arguments, so before going back to older code, set equivalent native rules yourself.

Cost at this head against main: source +206/-27 in 24 files, tests +416/-7 in 13, docs +54 in one. One migration and one new error code.
2026-10-25 09:18:20 -07:00
Mike Schwarz 50ad6102d1 fix(daemon): retain paste ownership across Claude footer hints (#1921)
A draft-aware message to a Claude seat pastes its text, then checks that the composer still holds only that paste before pressing Enter. When Claude replaces the mode footer with "paste again to expand" after the paste, that check read the composer as changed, so the message never submitted and later wakes could stall behind the leftover paste. The shared reader now treats those recognized noninteractive footer hints as part of the same composer. The lease, the original-empty check, the target and occupant, geometry, cursor and paste-label checks all remain. No extra input, delay or retry.

Refs #14. A native A/B on a fresh Claude seat delivered the long message and its dependent wake on both arms, so it shows no regression, but the original failure didn't reproduce, so #14 stays open.
2026-10-25 09:11:14 -07:00
Mike Schwarz 5e0db11ba9 docs(herdr): reopening adds a workspace unless you use --window; Herdr's status is its own (#2032)
Coming back to a Herdr view with `rig terminal open <view> --provider herdr` always adds a new numbered workspace; reusing an open one is the `--window` path from inside Herdr. The shipped `openrig-herdr` skill told agents to reopen with the explicit form, so people got a duplicate workspace. The skill, the getting-started guide and the capability map now say to focus the open space, or from inside Herdr run `rig terminal open <view> --window`, which reuses a matching space, and that `--provider herdr` adds a workspace each time. The `--provider` help text says the same.

A known-issue line: Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting (`needs-input`), because OpenRig doesn't feed Herdr a state. Use OpenRig's view or the Feed to find waiting seats.

Text only; the skill's mirrors and digests are updated together.
2026-10-25 09:05:34 -07:00
Mike Schwarz 6fa90223e7 fix(resume): find Claude's and Codex's choosers when drawn high in a tall pane (#2010)
A seat restored with `claude --resume` can stop at Claude's resume chooser ("How would you like to resume?", or a numbered conversation list). In a tall pane OpenRig reported it as resumed instead of asking a person to choose, and a Codex model chooser in a tall pane read as `awaiting_runtime` instead of `model_selection_gate`. The reverse happened at the bottom of an ordinary pane: an old chooser above a resumed session's composer read as live.

Both choosers are drawn at the top of a fresh pane, and tmux keeps the blank rows under them. Each window now ends at the last drawn row, and a chooser counts only while it's the last thing drawn. For Claude, nothing of Claude's own may follow the last option: a composer, a `Claude Code v` header or a reply retires it, and Claude's input frame (a rule, the `❯` row, a rule; the top rule carries the session name on a renamed seat) counts as the composer. A bare `❯ N.` option row under a live chooser is still an option. A conversation list's heading is read from the whole capture, so a long live list still reads as the chooser. For Codex, the options count only when no composer row or `OpenAI Codex (v` header follows them. The probe still never answers a chooser.

Known limit: a frame drawn under a still-live chooser would read as resumed; no recorded layout does that.

Tests: tall and bottom-reaching live choosers for both harnesses, history above resumed sessions, long lists, numbered drafts in labelled and plain frames, and the restore orchestrator's chooser cases.
2026-10-25 09:04:30 -07:00
Mike Schwarz ca3b229ac9 fix(codex): a seat's own topology folder is writable, so it keeps its LEARNED.md and recap (#2018)
On the default workspace-write floor, a Codex seat's own topology folder (`<topology root>/rigs/<rig>/seats/<seat>`, home of its `LEARNED.md` and recap) sat outside its writable roots. So the seat couldn't keep its own `LEARNED.md` for its next occupant, and `rig context recap-write` failed with EPERM. The folder is now added as an `--add-dir`, created first if missing (including the rig's folder), because Codex can't create a folder outside its writable roots.

It follows the git metadata dirs' rules: fresh, resume and fork add it after the git dirs, and on resume and fork after the conversation id, so the `ps` proofs still read the id when the path has a space. A rebuild keeps the observed envelope, a provably read-only profile gets none, and an in-place restart keeps the running process's own `--add-dir`s. The restore path's resume adds it only on the plain workspace-write floor. The name must be canonical with safe segments, and a symlinked `rigs`, `<rig>`, `seats` or `<seat>` segment gives no folder and creates nothing through the link.

Because the seat can now write its own folder, and the daemon writes there for it through the recap route with wider file access, the recap store now refuses to write through a link at `RECAP.md` or `recap-superseded/` (dangling links included). That's an intentional refusal of a layout that worked before. The current recap, the archive and the link's target keep their bytes, and the error is the existing `recap_write_refused`. The direct write, the daemon fallback and folder creation are unchanged; the check runs before the write and doesn't cover a change made in between.
2026-10-25 08:36:52 -07:00
Wu Shuwen 51232da54a fix(codex): key inline hook trust by its actual group position (#2028)
Codex inline hook trust was keyed as if the managed hook group were always at position zero, so OpenRig's trust entry could miss the hook Codex actually runs. Trust keys now use the managed group's actual position, the one Codex reads. A person's own trust hashes and disable choices are preserved, nothing new is trusted, and an old zero-position entry fails safe. The JSON hook route and hash algorithm are unchanged. Refs #17.

Thanks to @dajiaohuang.
2026-10-25 08:32:29 -07:00
Mike Schwarz f2a1fd5a1d fix(bundles): a service bundle install without Docker Compose v2 stops before the target or team, and a plan says so (#2025)
A service bundle installed on a machine without Docker Compose v2 used to leave a half-made rig. Install now runs a read-only `docker compose version` before the install target is written and the team is created, and stops with `compose_missing` if it fails: "The install target wasn't written and no team was created." A preview (`rig bundle install --plan`) still returns its `planned` result and `services_plan` stage, and reports the failed check as a warning that an install on this machine would stop first. No Docker daemon is needed for the preview. The message keeps Docker's own reason line, not its trailing `See 'docker --help'`.

When `compose up` or `compose down` fails with Docker's own missing-plugin words, the error names the missing Compose. With Compose missing and no containers labelled for the project, there's nothing to take down, so an install's rollback can remove the rig. With containers, or with Docker unreadable, it still fails and names the missing Compose. Empty container inventory doesn't prove volumes or networks were removed. Plain `rig down` keeps the rig record, and `rig down --delete` behaves as before.

Bundles without services never run the check.
2026-10-25 08:28:13 -07:00
Mike Schwarz 6de3d5d041 test: make route idle-wait expiry independent of runner speed (#2027)
`transport-routes.test.ts` checked that a timed-out `--wait-for-idle` send returns 409 and sends no text, using a 1 ms wait in real time. On a loaded runner the first pane observation could finish after that wait, which by design reads `target_activity_unknown` instead of busy, so the test failed now and then (it failed one required CI run). A fake clock now drives the expiry, so the test checks the same outcome without depending on runner speed. No product code changes.
2026-10-25 08:19:54 -07:00
Mike Schwarz 7dc1a7a9e6 fix(queue): a dead seat's recovery reaches the operator agent before the person approvals use (#2021)
When a seat dies holding work, the stuck sweep's finding now goes to a live recovery seat when one is available, in this order: the operator agent (`agents.operator_session`) when one is configured, the operator seat (`workspace.operator_seat_name`), then the stuck row's creator. A candidate is skipped if it's unset, if its own session is confirmed missing, or if it's the dead seat itself. If none qualifies, the finding is still created, since it's owed work, stays addressed to the missing session, and its reason says no live recovery seat was available. A finding that can't be created is reported as a failed pass, as before.

`workspace.operator_seat_name` is the person approvals reach (the workflow human resolver and Mission Control's default queue read it), so it isn't repointed. `agents.operator_session` already named the operator agent's session for the UI's operator chat; the sweep now reads it first, so recovery can reach an agent while approvals stay with the person. A person's address (`@external`, or a human seat such as `human@kernel`) never counts as the operator agent. With the setting unset, routing matches the previous behaviour whenever that fallback is eligible; the self and confirmed-missing filtering now applies to the operator seat and creator too. The wake ladder is unchanged.

Tests in `queue-stuck-sweep.test.ts` cover the agent-first route, a missing agent passed over, the no-live-seat case two ways, and how the setting resolves.
2026-10-25 08:04:04 -07:00
Mike Schwarz 32957b3edf test: cover inline Claude composer in a tall installed pane (#2022)
The installed prompt-state scenario (#2002) drew its working Claude frame at the bottom of the pane, so it couldn't catch the tall-pane case #2005 fixed: a fresh seat draws its input box near the top of a tall pane, with blank rows under it. The scripted pane is now 45 rows, with the frame inline and top-aligned and the cursor in the input. Admission checks the real pane height (at least 40), the exact full-screen row count, a drawn mode footer and at least 20 blank rows below it. New controls reject short geometry, truncation and an all-blank capture. #2002's other controls are unchanged.

On #2005's parent (`a5289131`) the scenario stops at the named #1955 assertion: the quoted-selector working send is refused, in a 45-row pane with 35 blank rows. At this head it passes. Test and docs only, 3 files, +23 -10.
2026-10-25 08:03:40 -07:00
Mike Schwarz faa25a35bf docs(harness): clarify startup checks and qualify Cursor first input (#2017)
`startup_assisted: true` means a person checks startup submission after the attempt. The first turn may already be running, including when `first_input` passed it at launch, so the docs keep the no-resend guidance and say plainly that it's a check, not a hold.

The Cursor reference preset now uses `startup_assisted: false` with its existing positional first prompt, and the obsolete assisted-paste limit is removed. In native qualification at `802d44a2` (Cursor Agent 2026.10.22-27dcf5f), a 1996-byte first prompt arrived as one positional argument and got the expected first reply, and same-chat snapshot resume worked. Later native tool approvals remain separate, and full unattended tool-workflow completion isn't claimed.

Runtime code, the launcher and the orchestrator's assistance cases are unchanged. One test assertion follows the preset.
2026-10-25 08:03:16 -07:00
Mike Schwarz 8ef1137b0e Support named OpenClaw conversations in local and Gateway seats (#2009)
OpenClaw seats can run either an embedded local agent or a TUI client of an existing Gateway. The reference launcher keeps OpenRig's named conversation, checks the exact native key at its owner before resume, and passes the generated first startup input through `--message`. Both examples use the existing format-1 `names_conversation`, `resume` and `first_input` fields, and each seat keeps its own client state. An unavailable lookup is distinct from a confirmed missing key, and neither silently starts a replacement. Stopping a Gateway client detaches it without stopping the Gateway or an accepted turn.

Native qualification on `8f3d7a6c` with OpenClaw 2026.10.6 covered the Gateway journey (first input, a task, snapshot, same-conversation resume, a useful reply with no startup replay) and, for the local agent, first input, a task and a readiness-gated resume of a completed conversation in the same session UUID. A resumed process doesn't mean the native TUI has finished its database startup checks, so the guide says to wait for native readiness before sending work. A local turn interrupted by a stop may leave native recovery work that refuses a later request; the local preset says to finish the current turn before stopping, and this launcher doesn't repair or retry it.

No core plug-in contract change. Docs, presets, the launcher and one example test.
2026-10-25 08:02:46 -07:00
Mike Schwarz fcd8db792e perf(queue): prepare the transition wake lookup once per repository (#2013)
Queue history reads call the wake lookup once per transition. Preparing the same indexed statement for each call added avoidable work to health-diagnosis reads with long histories. Prepare it lazily once per repository instance and keep every lookup fresh; no query results are cached. Availability, ordering, misses and errors retain their behavior.

Validation: 140 affected tests and daemon typecheck pass. Independent Codex and Claude source reviews passed on the identical two-file change. A real-database test checks a warmed miss, insertion, update and deletion.

In one synthetic daemon replay with 100 owned diagnoses and 6,900 history transitions, reference/candidate/candidate/reference evaluation means were 918/759/745/1,014 ms: combined 966 → 752 ms. All 12 evaluations retained complete coverage and all 16 native-consumer checks passed. Reference drift was 96 ms and individual ranges overlapped. This is a local improvement on that workload; the recurring severe-stall cause remains unresolved.
2026-10-25 08:02:25 -07:00
Wu Shuwen b7f8833b5a feat(queue): resolve typed gate blockers with recorded evidence (#1809)
`rig queue resolve` now also releases an item parked on a typed blocker, such as `external:<what>`, `fold:<what>` or `auth:<what>`, once that blocker is met. The supplied evidence is recorded with the transition as a wait resolution, not as a human decision, and the item moves back to in progress with its owner nudged, as `resolve` already does for an item waiting on a person. Items blocked on another queue item, malformed blockers and items that aren't blocked are still refused. The mission-control architecture doc describes the widened eligibility.

This is the widening agreed on #1785 instead of a new `satisfy` verb. Thanks to @dajiaohuang for the change and to @BillyOutlast for the report from their fleet.
2026-10-25 08:02:01 -07:00
Mike Schwarz b4060318d6 fix(transport): refuse ordinary sends at adopted Pi confirmations (#2015)
An adopted Pi TUI can keep an unknown runtime and has no managed-runner hook, and the shared pane classifier didn't recognise Pi's extension selector. So an ordinary `rig send` into a window waiting at an approval dialog typed its text and pressed Enter, which chose the highlighted answer (by default Yes).

The classifier now recognises the current Pi selector by Pi's own chrome: an option block with exactly one arrow-selected row, the navigate/select/cancel help row, and the closing border after it. The footer isn't read, so unnamed sessions, missing usage counters, extension status rows and below-editor widgets don't decide recognition, and rebound keys and any choice labels work. A later editor border or a closing Markdown fence marks the selector as history or a quote. The existing prompt guard then refuses with `target_needs_input` and writes neither text nor Enter. Ordinary unknown-runtime sends and the audited deliberate-interaction override are unchanged.

Limits: up to six non-blank rows may follow the selector's border (the footer, a status row and widgets). Past that, or under a widget that draws its own border, it fails open to the previous behaviour. Pi's text-input dialogs aren't selectors and aren't covered.

Tests: `send-prompt-guard.test.ts` covers ten layouts with null, unknown and Pi runtimes, selected No, history and quoted output. Source +24, tests +85.
2026-10-25 08:01:33 -07:00
Mike Schwarz d3dfd583a0 fix(context): a sandboxed seat can save its recap (#2008)
A default workspace-write Codex seat couldn't save its handover recap: `rig context recap-write` wrote the topology seat folder from inside the seat's sandbox and failed with EPERM, so a rebuild recovered only from git.

The CLI still tries the local write first. Only when the OS refuses it (`EPERM`, `EACCES`, `EROFS`) does it send the recap to a new `POST /api/context-packs/seat-recap`, and the daemon writes it. Both paths use one `writeSeatRecapAt` for the address checks: bounded names, an existing rig, no symlinked or file segment in the rig, `seats` and seat folders, and the existing supersession and addressability rules. A link already at the recap file or its archive folder is followed, as the local path always did. The Markdown heading parse the route uses is now a linear scan with the same accepted headings, so a long recap can't stall the daemon.

Validation: CLI and route tests cover the fallback, a timeout reported as UNKNOWN, an unreachable daemon and each address check; the heading scan matches the old regex on a large differential set and stays fast on the inputs that made the regex slow.
2026-10-25 06:48:11 -07:00
NIKHIL fb93eae9b7 fix(tui): preserve stream connection state across pages (#2014)
Moving between TUI pages forgot the activity stream's connection state. A new page treated a connected stream as disconnected and polled every 30 seconds instead of every five minutes, and returning to a cached page after a disconnect could bring back `connected` and wait five minutes for its next read. Connection state now belongs to the live refresh owner, outside cached page loads, so navigation keeps the real state and its cadence.

Thanks to @Nikhi00718.
2026-10-25 06:37:25 -07:00
Mike Schwarz fa5fa6e0d1 fix(queue): a dead seat's stuck-sweep finding goes to someone who can act (#2011)
When a seat's session disappeared, the stuck sweep addressed its recovery finding to that same dead seat, and the seat kept reading idle at prompt, so nobody was told.

A stuck-sweep finding whose route is a seat with a confirmed-missing session now goes to the configured operator seat, or else to the stuck row's creator, the floor the wake ladder already uses, and its reason names the missing session and where the finding went. That seat's activity reads unknown on the queue's surfaces. The check is one predicate reusing node inventory's own confirmed-missing rule, so no verdict, a stale verdict from before a relaunch or handover, or an unreadable store still reads not-missing. Live seats keep their findings, and orchestrator routing is unchanged.

Validation: the sweep, wake-ladder, node-inventory and projection suites pass, and the three routing cases fail on the old sweep. Daemon typecheck passes.
2026-10-25 06:26:30 -07:00
Mike Schwarz 54bd485118 docs(skills): delegating-work also triggers when judging a delegated result (#2012)
Agents asked how far to trust a delegated result didn't reach the delegating-work skill, although its body already covers that. The skill's description now ends with one trigger sentence: "Also use it to decide how far to trust a delegated result." The body and every other byte are unchanged; the shipped copies and digests match.

In a lab measurement (Sonnet 5.5, Claude Code 2.1.310, one skill listed), the skill activated on 5 of 5 runs for each of two target prompts and on 0 of 36 near-miss runs. There's no claim for other harnesses or models.

Thanks to skills-sme@lab, and to Matt Pocock's writing-for-agents and Lauren Tan's pstack Eval, which shaped the method.
2026-10-25 06:18:10 -07:00
Mike Schwarz 0c0ff0857d feat(provider): rig provider switch --seat moves a Claude seat to its own subscription token on the same conversation (#1980)
`rig provider switch --seat <seat> --token-file <path>` moves one Claude seat onto a prepared subscription token, on the same conversation, the same config directory and the same pane. `--stored-login` moves it back to the config directory's stored login.

- OpenRig stores a reference to the token file (a label and path), never the value. The launch reads the file as Claude starts and exports it only into Claude's process, so the value never reaches OpenRig's database, rows, argv or logs.
- The seat keeps its pane: Claude ends in place, a shell respawns in the same pane with its scrollback, and restore's own resume step brings the conversation back.
- The report names the source the seat actually bills. The token survives the managed environment reduction. Variables that would outrank it are unset for that one process, which also hides them from Claude's tools and hooks. With `--stored-login`, an inherited `CLAUDE_CODE_OAUTH_TOKEN` is reported rather than silently used, and a launch-time fallback warning reaches the report.
- Outcomes are honest. Only a refusal before Claude is touched reads as unchanged. A pane that was ended but not resumed, or a thrown step, reads `unknown` with the snapshot's recovery command, and nothing is retried.
- The whole switch runs in the seat's existing lifecycle lease, so a second switch waits and rechecks. A failed registration never kills a kept session. The printed way back quotes the token path.

One nullable column (migration 138) and one event type. No account registry, vault or new route. Native validation on prepared homes is next.
2026-10-25 06:11:00 -07:00
Mike Schwarz 65a31695b8 Explain how busy Codex seats handle incoming messages (#2007)
A `rig send` to a Codex seat that is mid-turn printed only "target pane appears mid-task; sent anyway". Codex folds such a message into its running turn, and in a measured run the turn then answered only the new message.

When the target seat runs Codex and no `--wait-for-idle` was passed, the advisory now adds that the message joins the running turn and may redirect it, and names `--wait-for-idle <seconds>` for separate work. `docs/reference/seat-delivery.md` says the same. Other runtimes keep the existing warning; sends stay advisory, with no new mode, refusal or default.

Validation: the existing busy-send test now covers Codex and Claude, and the session-transport suite and docs guard pass.
2026-10-25 06:09:53 -07:00
Mike Schwarz e9faaee2a9 fix(daemon): let down settle seats after tmux server loss (#1999)
After a team's tmux server dies (a crash, OOM or kill) and leaves its socket file, `rig down` reported "Could not confirm session … is absent: no server running" for every seat and stopped nothing, so `rig archive` refused live sessions and `rig up` refused a running rig.

`rig down` now treats tmux's own "no server running on <socket>" diagnostic from the daemon's probe as positive absence: it records the sessions as intentionally stopped and clears their bindings, without killing anything or starting a server, and archive and up then proceed. The rule lives in one shared predicate beside `SessionProbe`, used by teardown and the import guard, and matches only the start of tmux's diagnostic (stderr, or the line right after a `Command failed:` wrapper). A missing socket file, a permission failure, socket-path text and any later line stay uncertain, as before. The session-absence check uses the same start-of-diagnostic boundary, and the probe rethrows the original error so its reason stays readable.

Scope: a reboot that clears the socket directory is not covered. Validation: focused teardown, adapter and guard suites pass, including path-text, newline-path and mixed permission controls that kept the rig and binding, and daemon typecheck passes. Native acceptance (kill a disposable team's server, then down, archive and up) is separate.
2026-10-25 05:56:59 -07:00
Mike Schwarz 3623f4d648 test: cover pane-only attention and quoted prompt delivery (#2002)
A prompt visible only in a seat's pane must appear as needs-input across the API and CLI, and quoted approval text above a working Claude composer must let coordination messages through, while an actual waiting prompt still refuses them. Both regressions escaped to users (#1946, #1955).

Adds one installed `prompt-state` scenario using adopted scripted panes, the real daemon, CLI and tmux path, actual timer wakes, and a read-only check that no hook supplied the activity. It checks that attention clears on a working pane, that both quoted-text forms admit a send and a wake, and that a real dialog refuses both; the refused wake is tied to the watchdog's own attempt record and refusal reason. No production source or provider calls.

Validation: the installed case passes at this head in about 21 s in its disposable container (peak memory not sampled). On rebuilt historical sources it fails at the named assertions: the pane-only attention check on 32633a3d, and the quoted-selector working send on the parent of db863940. The scenarios use controlled pane layouts and don't prove native runtime behaviour.
2026-10-25 05:43:00 -07:00
Mike Schwarz 60497a7452 fix(transport): find Claude's input box when it's drawn high in a tall pane (#2005)
A fresh, working Claude seat in a tall pane (40 rows or more), with quoted approval text above its input box, refused sends and queue wakes as if it were waiting at a prompt, which is #1955's bug again. It also happened just after a cancel or clear redraw. Claude Code draws inline, so the rows under its footer stay blank, tmux keeps those blank rows in a capture, and `findClaudeComposer` took the last 20 physical lines before dropping blank ones, so in a tall pane it saw only blanks.

The 20-row window now ends at the last drawn row. The bound and column preservation are unchanged, and the status scan above the box still stops at the nearest unindented row. The draft-aware typing guard and the running check use the same function.

Validation: the tall-pane cases fail on the old source and pass after. Every dialog layout drawn high, with blank rows under it (permissions, the question picker, the MCP menu), still reads as attention, and sends to them are still refused. Native confirmation in a tall pane is separate.
2026-10-25 05:40:59 -07:00
Mike Schwarz a528913135 docs: explain first-user config scope and route project work to a team (#2001)
A person who declined `rig setup` could still get Codex config writes when the kernel started, and a project task handed to the kernel operator could be implemented there instead of by a team.

The installer guide now says, before installation and startup, what each step writes: setup writes `~/.tmux.conf`, and starting Codex seats writes workspace trust and selected config fragments to the home `config.toml`, which skipping setup or turning hooks off doesn't suppress. A separate Codex home also selects login and session state, and a project config or worktree doesn't redirect these writes. It explains why individual approvals prompt often, keeps the one optional remembered-rule offer, and rules out broadening other settings to avoid prompts. The operator's role and startup context put project work on the chosen team's queue.

Guidance only: no setup mode, configuration or runtime change. Validation: docs guard, the first-run reference checker and the global-skill fixture checks pass, and the canonical and packaged `rigs` skill copies match. Whether agents follow it is for the first-user checks.
2026-10-25 05:09:25 -07:00
Mike Schwarz 74f5519775 fix(restart): an in-place restart keeps a writable root whose path holds a space (#2003)
On Linux, `rig seat restart --keep-conversation` rebuilt a Codex seat's resume from `ps` output split on whitespace. With a cwd such as `/home/u/my repo`, the restart passed `--add-dir '/home/u/my'`: the seat lost write access to its `.git` and gained it on an unrelated prefix.

The restart now reads the running process's exact argv from `/proc/<pid>/cmdline`, next to the `/proc` reads it already makes, and uses it for the writable roots, network and daemon options, and the settings and effort comparison. A value containing a space no longer reads as settings drift. If `cmdline` can't be read, it falls back to `ps` as before. Nothing new is refused. The process facts are still read separately, as before; this is not a guard against pid reuse.

Validation: the new restart case fails on the old source and passes after, and the restart suites and daemon typecheck pass. The real `/proc` reader test runs only on Linux, so CI's macOS runner skips it; the NUL-splitting parser is tested on every platform.
2026-10-25 05:00:55 -07:00
Mike Schwarz a37e271f60 docs(skills): correct vendored sources and include upstream licenses (#2004)
Contributors now get the correct vercel-labs/agent-browser source link and an accurate description of our retained copy, rather than an unsupported "tracks upstream" claim. Its upstream Apache-2.0 license travels beside it and beside dogfood; the three shipped obra/superpowers derivatives (systematic-debugging, test-driven-development, verification-before-completion) also include their upstream MIT license. Check dates name the compared upstream revisions and say no content sync occurred.

Skill bodies, triggers and companion workflow files are unchanged. Mirrors, edge digests, the docs guard and the exact upstream license bytes are verified. Metadata, notices and regenerated hashes only.

Thanks to the authors of vercel-labs/agent-browser and obra/superpowers.
2026-10-25 04:38:53 -07:00
Nash Aschenbath 5b6bc7b9fa fix(ui): distinguish skipped kernel boot from unknown status (#1917)
The dashboard shows a neutral "Boot not tracked" badge for `kernel_state: "skipped"`, keeping the daemon's reason visible. An existing managed kernel and an explicit auto-boot opt-out can both produce this state; ready-agent counts do not turn it into a healthy verdict. Unavailable or malformed responses still show "Unknown".

The added component cases fail on unchanged production code and pass with the fix; they exercise the real query hooks and rendered card with synthetic HTTP responses. The dashboard documentation describes the new state.

Reported by @Kero-66 in #1915.
2026-10-25 04:32:47 -07:00
Mike Schwarz 1e5d939022 fix(codex): a resumed or forked seat keeps its git dirs writable, so it can still commit (#2000)
A Codex seat in workspace-write could commit, but after `rig down --snapshot` and `rig up --existing` it resumed without `.git` among its writable roots and could no longer commit. The fresh launch adds `--add-dir` for the seat's git metadata (#121); `codex resume` and `codex fork` were sent without it.

`prepareLaunch` now resolves the git dirs once per launch, with the same rules as before: a linked worktree's real git dirs, never a `.git` file, and none for a provably read-only profile. Fresh, resume and fork all pass them, and a fresh launch's command is byte-identical. A rebuild keeps its observed permission envelope, and an in-place restart reuses the running process's own dirs. On resume and fork the git dirs follow the conversation id, so the `ps`-based resume and fork proofs still find the right process when the cwd contains a space; Codex 0.164.0 parses options after the id as options.

Validation: 11 resume and fork cases fail on the old source and pass after; the Codex adapter suites pass, including new cases with real temporary repositories and linked worktrees. Two independent reviews, one with a security lens. Native acceptance (commit, restore, commit again) is separate. The pod-less legacy resume path is unchanged.
2026-10-25 04:30:22 -07:00
Mike Schwarz 68d308b777 perf(transport): batch producer-link environment checks (#1994)
Unknown-readiness diagnosis currently runs a full tmux environment listing for each relay prerequisite alias while holding the seat’s send lease. Read requested presence booleans from one fresh listing instead. Keep scalar fallback for custom adapters; a failed batch returns UNKNOWN without exposing values. This intentionally uses one snapshot and one failure outcome rather than separate alias reads.

Validation: 559 focused tests and daemon typecheck pass; independent Codex and Claude reviews pass. A single instrumented comparison recorded lower preflight intervals, but both arms still had one five-second timeout. This removes redundant subprocess work; it does not claim to eliminate send timeouts.
2026-10-25 04:01:54 -07:00
Mike Schwarz 7bb5ef6513 docs: add published service-bundles note to 0.7.1 changelog (#1997)
CHANGELOG 0.7.1 now includes the service-bundles entry already published in the 0.7.1 release notes, as the last "Running a team" bullet before "Reliability".

The entry matches the published body after whitespace normalization and the changelog's heading nesting, and it is the only insertion. Documentation only: no release, package or version change.
2026-10-25 03:59:44 -07:00
Mike Schwarz 5575c123b1 fix(daemon): surface Pi extension dialogs and attention (#1996)
A Pi extension dialog is shown in the seat pane and cancelled, with the seat marked as needing attention. The signal survives turn end and idle heartbeats until a new agent run starts. Extension notifications are shown without generating a response or attention.

The runner reuses the existing RPC cancellation policy: it replies only with the request's own id and cancelled: true, so nothing is approved, and it cannot answer dialogs in the pane. The pane explains the cancellation and asks the operator to review the request. Dialog title and message are stripped of terminal and control characters and capped before display. OMP behaviour is preserved.

Validation: five new controls fail on the prior source and pass after the change (the four dialog methods and a notification); the Pi and OMP runner suites pass with one worker. Two independent reviews, one with a security lens. Real Pi acceptance is separate; no native process or provider call ran.
2026-10-25 03:53:58 -07:00
Mike Schwarz 8d55a89dcb chore(release): the packaged-plugin baseline is the published 0.7.1 (#1995)
The packaged-plugin baseline now records the published 0.7.1 instead of 0.7.0: tarball sha256 c1637bbe (matching npm's integrity) and openrig-core 0.1.18. It's generated by the script's own refresh command from `npm pack @openrig/[email protected]`. The 0.7.2 cut's plugin check compares against the release users actually have.

Fixture only: +4/-4. No code changes.
2026-10-25 03:46:28 -07:00
Mike Schwarz fb4e0ab7e3 fix(cli): clarify credential restore does not verify sign-in (#1991)
`rig provider switch`'s printed undo and `rig auth switch` restore a saved Codex login's bytes. They now say only that. The output says sign-in is unverified, that saved credentials may expire, be revoked or be superseded by a refresh elsewhere, and that you should restart the affected seat and use the existing account verification, since a fresh sign-in may be needed.

Before this, the undo printed `activated_profile` and exited 0, which reads like restored access. Codex keeps a refreshed token only in its active `auth.json`, so an older saved copy can go stale.

Text only: +23/-3. No behaviour, refusal or JSON change.
2026-10-25 03:31:30 -07:00
Mike Schwarz b46ce920d1 fix(daemon): recover pending wakes after listening (#1993)
A backlog of queue notifications can hold startup in sequential verified deliveries until the CLI readiness deadline expires. Start the daemon-owned recovery pass after listening. During shutdown, finish the delivery in flight and stop later claims, including released members of combined wakes; unattempted notifications remain for the next start.

Validation: 125 focused tests and daemon typecheck pass, with two independent source reviews. An independent four-stub-seat, 40-notification replay reproduced the reference readiness failure at 21.17s; the repair reached health at 2.96s and attempted all 40 (20 render-verified, 20 awaiting confirmation). Consuming-agent acknowledgments and large-database startup remain unqualified.
2026-10-25 03:29:25 -07:00
Mike Schwarz 44676cb09c docs: carry 0.7.1's published release text to main (#1990)
Main now carries what 0.7.1 published.

- `CHANGELOG.md`'s 0.7.1 entry is the published v0.7.1 Release body, word for word, reflowed to the file's line form. That includes the credits for all reporters and all 22 known issues. It replaces the earlier draft the tag carried.
- `docs/reference/whats-new.md` carries 0.7.1's entry from the tag, with one line narrowed to what's true at source.
- The root, cli, daemon and ui package versions and their lockfile entries move from 0.7.0 to 0.7.1, matching the v0.7.1 tag. The packaged whats-new must name the package's own version.

No other changes.
2026-10-25 03:14:00 -07:00
Mike Schwarz 802d44a2a9 feat(daemon): pass community first input at launch (#1987)
A community-harness preset can now set `first_input: true`. OpenRig then hands the harness its first startup turn at launch, through the harness's own route (OpenClaw's `--message`, Cursor's positional prompt), instead of pasting it into the pane after start. Without this, OpenRig could report a seat ready while its identity text sat unsent in the composer.

- OpenRig composes exactly the first startup text it would have pasted, writes it to a per-attempt 0600 file in an OpenRig-owned folder, and passes `OPENRIG_FIRST_INPUT=<path>` at launch. The preset's launcher hands it to the harness.
- Only that one send is skipped. Later startup files, `after_ready` actions and ordinary sends go through the pane as before.
- The record says the first input was passed at launch, not confirmed submitted, and only once the transport actually attempted input. An uncertain launch never replays it.
- Readiness stays process-only, and `startup_assisted` is independent. Built-in runtimes and presets without the field don't change.

The two reference launchers (OpenClaw, Cursor) use it. Argument-based harnesses see the first turn in their argv, which may face platform argument-size limits; that's in the presets doc.
2026-10-25 03:06:36 -07:00
Mike Schwarz 6676a44539 test(cli): run MCP health, slice, mission and context reads through real routes (#1988)
The MCP read tools `rig_status`, `rig_slices`, `rig_mission`, `rig_context_pack` and `rig_context_get` were tested only against a mocked client. Now each runs through the daemon's real routes too, so a change to `/healthz`, `/api/slices`, `/api/missions` or the context-pack library response fails CI instead of silently breaking the tool for an MCP client.

The cases check what a client would see: healthy status, filled slice and mission summaries, a cut page that says so, pack and file contents, and no local path in any answer. `rig_status` covers only the healthy pass-through; an unhealthy daemon's mapping isn't exercised here.

Test only: +77/-2 in `packages/cli/test/mcp-operations-daemon.test.ts`. No source change.
2026-10-25 02:51:31 -07:00
Nash Aschenbath cb8373cc07 fix(tui): load health findings for focused summaries (#1986)
A focused TUI view that shows a health summary (the instance Table, the rig and pod Table, and Overview) now loads the existing health projection that summary displays. Served findings and coverage appear after hydration instead of staying at "Not assessed". Other focused views keep the findings read lazy.

An empty projection still reads as "not a healthy verdict", and a 503, an unrequested read or a remote-host summary stays honest. Pod summaries keep the rig scope. The still-resolving `pendingDrill` first frame isn't covered.

This carries @Kero-66's original fix and report forward, with their commits and authorship kept, through @Aschenbath's resubmission. Thank you both.
2026-10-25 02:44:15 -07:00
Mike Schwarz bff6dba887 fix(queue): read a create with an unknown outcome back once by its ID before reporting it (#1978)
When `rig queue create` gets no answer in time, or an unreadable one, the CLI now reads the row back once by its ID before it reports anything. The create is never sent a second time.

- A row that is exactly this create is reported as created: "The create committed after all: row … (read back by its ID)", exit 0, printed as for any create, with `--verify`'s delivery result when it was asked for.
- A row with that ID that isn't this create stays INDETERMINATE and names what differs (destination, source or body), with "Don't reuse this ID for other work".
- No row yet, or a read-back that fails too, stays INDETERMINATE and says why. The `--id` retry is kept.

"Exactly this create" means the same destination, the same body, and the source the daemon actually stores for this request. The CLI takes the sender it actually sent, canonicalizes it as the daemon does (trimmed, its own host suffix removed), and stamps the forwarding daemon's host id only on a genuine forward. A `--host` naming the daemon's own id is a local create, unstamped, by the daemon's own self-host rule. When the host id it needs can't be read, the outcome stays unknown. No host suffix is optional.

Before this, a daemon that answered after the deadline left the person with a failure or an unknown outcome while the row already existed, so an agent could create the work twice. #1911 made the timeout read INDETERMINATE; this adds the read-back on top. No new refusal, replay, route or flag.
2026-10-25 02:40:10 -07:00
Mike Schwarz 9ea859e2cf docs(seat-delivery): list all six typing-guard modes (#1989)
`docs/reference/seat-delivery.md` said the typing guard has three modes. Since #1770 it has six. The page now lists `openrig-urgent`, `urgent-only` and `sends-only` alongside the others, in the intro, the mode table and the `--mode` line, using the CLI help's words. It also states the existing rule that lifecycle work is refused at `openrig-urgent` and `urgent-only`.

Docs only: +10/-5, one file.
2026-10-25 02:33:50 -07:00
Mike Schwarz 5c2dac7910 test(testbed): use Node 22.23.4 and verify downloads (#1902)
The internal testbed image moves from Node 22.22.1 to 22.23.4 (CVE-2026-111497). Its download is checked against Node's official SHASUMS256, and the check binds the extracted archive, so a mismatch stops the build.

The scenario runner now settles its containers by recorded id instead of `rm -f`. It removes the cidfile only after a confirmed removal, so a re-run into the same folder isn't refused, and it prints a retained container's name.

This image isn't shipped. Its rebuild hasn't been qualified on its own: the first ordinary run of the route that consumes it is the proof.
2026-10-25 02:25:17 -07:00
Mike Schwarz 59a3573e91 fix(restore): a team of terminals reads fully restored, and rig down names the terminals it stops (#1984)
After a power loss and `rig start`, a team of plain terminal seats that restored fresh now reads `fully_restored` instead of partial. A `builtin:terminal` seat runs no agent command, so a fresh shell is the whole seat. #1328 already relaunches such a seat fresh, but the restore rollup counted it as a lost conversation.

Restore now records `noConversation` on an ordinary terminal seat's result, using the same predicate that relaunches it fresh, and only when its saved startup actually ran. These still make a rig partial: an agent seat started fresh, a terminal with a resume token or a changed runtime, a terminal whose saved startup failed, and any failed, waiting or attention seat. `rig restore status` keeps older attempts' recorded verdicts.

`rig down`'s notice now names the terminal sessions it stops.

The real check stays Fleet's power-loss run on the 0.7.2 candidate.
2026-10-25 02:19:40 -07:00
Mike Schwarz 17c8bf4f9f docs(roadmap): plan 0.7.2's selected themes, each if ready (#1985)
ROADMAP.md's "Planned for 0.7.1" heading is now "Planned for 0.7.2", since 0.7.1 is out. It lists 0.7.2's selected themes, each marked "(if ready)": outside assistants over MCP, more harnesses as seats (Hermes, OpenClaw and Cursor), per-seat accounts, member permissions, stream decisions, and named people in Slack. The existing Subscriptions line moves with it, unchanged. No dates and no other changes.
2026-10-25 02:11:19 -07:00
Mike Schwarz 8d80110f3e fix(daemon): recognize Codex 0.162.1 sign-in chooser (#1983)
A signed-out Codex 0.162.1 seat reports that sign-in needs attention and gives the existing `rig seat continue` hint, instead of waiting for the readiness timeout. This extends Aschenbath's #1967, which did the same for Codex 0.164.0, to the older chooser: its two-line introduction and three numbered choices.

The same shared matcher accepts the older heading as a second form. The panel must be complete and current: both heading lines exact, exactly three choices in order with one selected, and the continue footer last. Partial or quoted panels, unverified wrappers and a newer composer don't become sign-in gates. No text or keys are sent. Existing 0.164.0 matching is unchanged.

The available 0.162.1 capture covers the chooser only, so its API-key entry screen isn't newly qualified.

Source +11/-4; tests and fixtures +39/-17. No new setting, reason code or dependency.
2026-10-25 01:38:46 -07:00
Mike Schwarz 71c1f817bd docs(arteries): regression-log lines for what the 0.7.0 and 0.7.1 cuts caught (#1982)
Four lines in docs/as-built/arteries.md's regression log, in its existing form: date, issue, artery, and which layer missed it. Nothing else in the file changes.

- F1 (0.7.0 candidate), launch: a fresh Codex kernel stopped at Codex's hook review. Caught at the cut by native first-user and daily runs; missed by the stub layer. Fixed in #1824.
- B6 (0.7.1 candidate), launch and resume: an upgraded Codex seat stopped at "Hooks need review: 6". Caught at the cut by a native upgrade run; missed by source tests, stub scenarios and the installed-upgrade check. Fixed in #1948.
- Pane-only attention (0.7.1 candidate): a Codex seat at a prompt only its pane showed read as working. Caught at the cut by real work; missed by fixtures that all carried hook signals. Fixed in #1946.
- #1955, delivery: quoted approval text in a working Claude seat's output read as a waiting prompt. Present since 0.7.0, found during the 0.7.1 cut by real work, and fixed for 0.7.2.

The log exists so each line becomes a scenario; where one is owed, the line names it. Docs only: +4/-0.
2026-10-25 01:23:51 -07:00
Mike Schwarz 7860337c86 docs: remove roadmap items shipped in 0.7.1 (#1981)
ROADMAP.md stops listing work 0.7.1 already shipped. The five Planned 0.7.1 entries that the published release delivered are removed: team skills, interruption levels (including terminal presets), permissions, bundles, and community contributions, each matched to its merged milestone PRs and the published 0.7.1 release notes. The subscription entry stays, because 0.7.1's boundary record marks that increment partial. Nothing else in the file changes: no rewording, dates or new entries.
2026-10-25 01:14:10 -07:00
Mike Schwarz 1de6984464 feat(provider): rig provider switch --seat moves one Codex seat to a login saved in its home (#1972)
An agent can move one Codex seat to another subscription login with one command, and the seat keeps its conversation: `rig provider precheck --seat <seat> --to-account <name>` shows the Codex home the running seat uses, what the seat is doing and every peer on that home, and `rig provider switch --seat <seat> --to-account <name>` performs the move. Before this, `rig provider switch` always answered `failed_safely` with `switch_execution_not_yet_wired`.

How it works: the switch composes `rig auth`'s file operations, now in the daemon (`@openrig/daemon/codex-auth`, with the CLI path a re-export), and the in-place `rig seat restart --keep-conversation`. It plans the restart first, which stops nothing, and checks that the login is saved in that home. It then saves the outgoing login as a unique `before-switch-<time>` copy, never replacing an earlier one; a save that fails for any reason other than "no active login" stops the switch before anything changes. It activates the chosen login and restarts the seat in place. The report reads `rebind_in_progress` with the verify step, `failed_safely` with the way back, or `unknown` with the restart's recovery command, never retried. Switches on one Codex home run one at a time inside the daemon, and a switch that waited behind another rechecks its seat before it changes the login. Every same-home peer is listed with what will happen to it, from Codex 0.164.0's own source: it keeps its old login in memory until its next token refresh, then stops with "signed in to another account". Each peer gets a pasteable `rig seat restart <peer> --keep-conversation --reason ...` step, and the person is told not to sign in again in that home. After a CLI timeout, the message says the switch may still be running or queued behind another.

Limits: Codex seats only (a Claude seat is refused with `rebind_unsupported_for_runtime`), Linux only (the restart reads `/proc`), and the login must already be saved in the seat's own home. A restart that never settles holds its home's queue; switches there wait, and their CLIs report unknown after 120 s. Auth contents never enter returned metadata, logs or argv: files are copied by descriptor through a scrubbed buffer.

Validation: the new tests fail on main and pass now, including a failed outgoing save, two switches in one second, overlapping switches on one home, and a switch whose seat became ineligible or moved home while it waited. The provider-switch and route suites and the CLI tests pass, and the independent security review's real-file controls pass. Native proof with two logins in one home and a control seat is Quality's.
2026-10-25 01:01:10 -07:00
Mike Schwarz 2323f9bc42 fix(daemon): stop startup Enter after a late Claude refusal (#1976)
Claude can refuse a startup request, for example on an expired login, after OpenRig has checked readiness for the next startup input, so the next input could be pasted and submitted into the refused turn. OpenRig now checks again before that later input's Enter, using the existing refusal classifier. A visible refusal leaves the paste alone, holds the remaining inputs as owed, and reports attention required. The message says OpenRig pasted the next input but withheld Enter, so it may still be in the input box and shouldn't be resent. A fresh start keeps the fresh-launch recovery command; a resumed or forked start keeps its conversation and says to clear or send the staged text after signing in. `seat continue` can't replay the staged input.

Cost: one extra readiness observation per later Claude startup input, and manual inspection when a refusal leaves text staged; no added sleep or request-completion wait, setting or code. A refusal arriving after the final observation can still race Enter, so this isn't an atomic delivery guarantee.

Validation: an ordered no-model discriminator drives the real Claude readiness check, classifier and file delivery with simulated terminal I/O, using the retained native refusal frame. A refusal after the check, or after the paste, let the next Enter through on the old source (2 of 6 cases) and is held now. Healthy, stale-refusal and unavailable-capture controls keep their behaviour. The message correction failed four staged cases before it and passes now, and the startup-orchestrator, startup-submit-confirmation and native-resume-probe suites pass. Native invalid-login and same-home sign-in recovery remain unverified by this change.

Follow-up to #1869.
2026-10-25 00:44:51 -07:00
Devulapalli Naga Sri Vaishnavi 0c1080927a fix(daemon): allow delivery with sibling tmux panes (#1616)
A managed seat can keep an unrelated project pane beside its recorded tmux pane. `rig send` and queue wakes now keep targeting the recorded pane instead of refusing the whole session because a sibling pane exists. The guarded write still requires the persisted bound pane to be present and writes to that immutable pane ID, not to the session's selected pane. The readers that decide whether the seat can take input read the recorded pane too: the readiness probe's terminal command and screen captures, the fresh-hook veto, the Codex foreground check (the session name is kept for identity), and the copy-mode cancel. The private unbound probe and other paths without an immutable managed pane stay single-pane strict.

Validation: a sibling-pane regression fails on main with `ambiguous-pane` and passes now; split-session tests where the recorded pane differs from the selected one cover the fresh-hook veto and the Codex foreground check, and the existing probe-context expectations now name the recorded pane. The native-claude-delivery, seat-delivery-guard and session-transport daemon tests pass, and lint covers the daemon build and typechecks.

Thanks to @Vaishnavi220506, and to @tianqizhao0704 for the precise report.
2026-10-25 00:38:32 -07:00
Mike Schwarz ff101e3f8a fix(export): carry culture and explain team sharing limits (#1975)
Sharing an exported team no longer silently omits its local culture file. `rig export` copies the declared file's bytes next to the YAML for a local daemon, creating declared subfolders, keeping any existing file or symlink with a check-it warning, and skipping a source or destination that would leave either folder. A missing, unsafe or remote source is reported as could-not-copy alongside the existing guidance, and the YAML export still succeeds. Export also names each seat whose agent ref leaves the folder, whether `path:`, absolute, or a relative `local:../` ref like the shipped starter team's, and says how to make it portable. It warns when the installed name remains `starter`, since a bundle's name doesn't rename the rig. The bundle guide covers archive vendoring, README inclusion and checking the culture file over localhost tunnels.

Cost: one declared culture-file copy on local export plus targeted guidance; no agent-tree copying, automatic renaming, new API or live-rig change. Remote and tunnelled exports still need the culture checked from the daemon host, and a successful export is still distinct from a portable, validated bundle. Validation: the escaping-relative-ref case fails before the fix and passes after; 31 export/import checks and the CLI typecheck pass at this head, and the first head's 148 focused checks and daemon build passed. The symlink fixture is skipped on Windows.
2026-10-25 00:31:21 -07:00
Devulapalli Naga Sri Vaishnavi 81c5df01d9 fix(daemon): start fresh pod seats from persisted recipes (#1503)
A pod-aware seat that has never successfully launched is no longer left at a tmux shell after a daemon restart. OpenRig persists the validated launch recipe when it materializes the seat, and when restore has no startup context and no resume request, it uses the existing validated pod launch effect to start a fresh harness. The seat's saved model, policy, effort, environment, session source, restore policy and cwd stay authoritative over the recipe's defaults. Resume stays fail-closed when there is no durable startup context. A recipe-backed fresh launch starts the provider harness and may consume provider usage; that is the intended consequence of repairing a seat that otherwise has only a provisional shell.

Limits: only pod-aware seats with a validated persisted recipe and a matching member definition fresh-launch automatically. The recipe's shape, including every member launch addition, is checked before the provisional shell is retired; legacy seats, malformed recipes and recipes whose member was removed surface attention_required with the shell and binding kept. A recipe is configuration evidence, not proof that the provider executable, credentials, workspace or external files are still available, so those checks stay in the normal launch path. The validated rig specification is stored per node so one seat can be rebuilt after a restart, a small storage cost for per-seat recovery. The new migration is 136_node_launch_recipe.

Validation: composed tests through the real restore-to-instantiator callback cover no recipe, invalid JSON, a malformed member addition and a missing member, each ending attention_required with the shell and binding kept, and the saved model, effort, Codex profile, cwd and permission policy reaching the launch input without stubbing the launch binding. Daemon build and lint pass, and the node-launch-recipe, restore-orchestrator and topology-ingress tests pass on the author's machine; six existing Windows path assertions are unrelated.

Refs #49. Thanks to @Vaishnavi220506.
2026-10-25 00:29:30 -07:00
Mike Schwarz 7b261c3b7b fix(compaction): the post-compaction turn boundary reads past the compaction output above the input box (#1971)
A Claude seat that OpenRig compacts now gets its post-compaction turn boundary on the next poll, so its restore starts by itself. Before this, the boundary waited for whatever changed the screen next: about 70 s in a native Claude Code 2.1.309 run, when Claude's own 60 s idle notice happened to arrive, and 21 minutes on a seat whose activity hooks didn't reach the daemon, until someone typed to it.

Why it waited: right after `/compact`, every row above Claude's input box is indented compaction text and PostCompact hook output, so `findClaudeComposer` never reaches the status head within its 20-line window and the pane reads `unknown/no_activity_signal` by design. The post-compact drain is pane-only, because the newest hook can predate `/compact`, and the hook after compaction (`SessionStart` with source `compact`) reads unknown too.

The change: the managed compaction's turn-boundary send, and only that send, passes `postCompactTurnBoundary: true` beside `readinessFromPaneOnly` to the pane classifier. For that send, a complete framed empty Claude box with its mode footer reads idle when every row above it is Claude's tool or hook output, that is a `⎿` row or its continuation indented five or more columns, and none is a task-list row. That is the post-compaction screen. A working seat's streaming reply text (two columns in) or a task list pushing the live status out of view still reads unknown. Live status, a typed draft and dialogs are checked first and still refuse. Every other send, the restore and audit stages, the input-box check after an uncertain `/compact`, the typing guard and the occupant gates are unchanged.

Validation: the new file's cases fail on main's classifier and pass now, including the hidden task-list and streaming-reply screens and the real `SessionTransport.send`. 1,348 tests across the 35 daemon files that exercise the classifier, the activity probe and the compaction enforcer pass, and the daemon typecheck is clean. Replaying the native 2.1.309 run's 318 captures: all 91 stall captures go from unknown to idle, the 12 compacting captures stay active, and none changes outside the stall window. An unassisted native compaction is still to be run. Cost: source +38/-4, tests +165/-7; no new setting, refusal, code or timer.
2026-10-25 00:00:56 -07:00
Mike Schwarz 6d58c28954 docs(rigs): offer operator handoff after Codex rules decline (#1973)
A fresh Codex user who declined Codex's offer to remember an OpenRig command rule then approved 22 escalations one by one during setup (23 requests, one interrupted; 21 began with `rig`). The rigs skill now explains, when that offer is declined, that even routine status and screen reads may keep prompting, and offers the existing operator conversation. Only after the user explicitly chooses the operator route does the installer stop polling, and the operator follows the work through the team lead's queue task and its handoffs. The default Claude installer and a Codex installer whose user accepts the rules keep their existing relay and follow-through duties, and users who prefer the installer keep their chosen per-command approvals. The rules offer now says it applies from the next session, and both handoff routes carry the user's constraints and declined choices.

Text only: the rigs skill, its identical shipped copy and the kernel operator role. The existing optional command rule, sandbox and setup behaviour are unchanged, and general network access is not enabled as a workaround. Checks: diff check, docs guard, skills mirror check and byte equality of the two skill copies; text scenarios cover the Claude default, Codex accepting, Codex declining and choosing the operator, and Codex declining and staying. No native prompt-count improvement is claimed; a fresh-user native trial is Quality's.
2026-10-24 23:58:21 -07:00
Mike Schwarz b6fb4ca482 fix(daemon): keep blank-first multiline drafts out of idle (#1977)
Someone typing a multi-paragraph message in a Claude seat's input box that starts with a blank line could have an agent's `rig send` typed into or submitted with their unsent text: the box's empty prompt row plus its mode footer read idle without the continuation text being inspected. The classifier now reuses `composerRegion` just before its idle shortcuts: when the Claude prompt row is empty but the recognized box body has text, it returns unknown, so the typing guard and the composer's stop-and-show protect it. Codex seats are excluded, and the existing input guard, deliberate-input handling and delivery paths are unchanged.

Validation: the new fixture (a blank first prompt row, an indented draft on the next row, a framed box and the accept-edits footer) reads idle on unmodified source and unknown now; 216 tests pass across claude-warning-footer-readiness, composer-input and seat-delivery-guard. An independent base/candidate/composed matrix confirmed the change, that empty and whitespace-only boxes keep their result, that Codex's empty-placeholder box stays idle, and that it composes with the post-compaction boundary change in #1971. This repairs the tested blank-first shape, not every draft layout. Cost: 7 source lines and 7 test lines, no new refusal, setting or code.
2026-10-24 23:49:30 -07:00
Nash Aschenbath b6c0e3140d fix(daemon): surface Codex first-login gates before timeout (#1967)
When Codex is waiting at its initial sign-in chooser or API-key entry, startup now reports `attention_required` with `login_required` and a sign-in explanation, instead of eventually reporting a generic readiness timeout. Startup context stays pending, nothing is typed into the login screen, and `rig ps --nodes --json` shows the reason and the continuation hint through the existing startup and inventory path.

The shared probe requires a native Codex process, or the adapter's verified native descendant, and a current, complete login panel with nothing after it. It distinguishes older composer text above the panel from a newer composer below it, and an unverified wrapper is checked and recaptured before readiness or menu automation. Detection is limited to the captured complete English chooser and API-key layouts; other authentication screens keep their existing handling. Claude's refusal handling from #1417 is unchanged: its persistent "Not logged in" footer alone still doesn't prove a login blocker for gateway users.

Validation: fixtures captured from the official Codex CLI 0.164.0 in a fresh container with no credentials and networking disabled, covering the chooser and API-key screens in wide and narrow terminals and managed --no-daemon launches. The new regressions fail on the original probe; 558 focused readiness, adapter, startup and inventory tests pass. The startup regression uses the real Codex adapter with captured terminal content, real SQLite persistence, the nodes HTTP route and the CLI JSON projection, and checks the attention status, the login explanation, held startup context and zero delivered files or keystrokes. This verifies recognition and startup propagation, not a completed native login. docs/reference/help.md covers finishing the sign-in and continuing.

Addresses #6. Thanks to @Aschenbath.
2026-10-24 23:46:44 -07:00
Nash Aschenbath d41fb29ac2 fix(daemon): attribute stuck-sweep closures to the detector (#1811)
When the stuck sweep closes its own finding, its transition history and `queue.updated` event now name `stuck-sweep@system` as the actor. Previously they credited the finding's source agent with that automatic action. The finding keeps its source, destination and claim, and the underlying work is unchanged. The closure query no longer reads the unused `source_session`, and the coordination architecture document explains the difference between ownership and the actor.

Addresses the closure-attribution part of #1419, following the retry-suppression fix in #1449. It covers attribution only, not every delivery or pickup evidence-policy case in that issue, and existing transition history isn't rewritten.

Validation: three regression cases failed before the fix, recording the source agent instead of the sweep, and pass after it; 183 tests pass across the stuck-sweep, human-custody, wake-ladder and pickup daemon files, covering pending and claimed findings, durable transition and event actors, unchanged ownership and source work, and no second closure on the next sweep. A posted human notice keeps the person's original ask open. Build, lint and the UI tests pass.

Thanks to @Aschenbath.
2026-10-24 23:38:10 -07:00
Mike Schwarz 70c39372d5 fix(daemon): say so at startup when the database-conflict check can't tell (#1974)
At startup the daemon checks whether another running OpenRig daemon already has the same database open. An inconclusive check used to read exactly like a clean one, so someone who turned on `daemon.db_conflict=stop` got no sign that it hadn't run. Now a check that can't get an answer adds one warning to the existing database-warnings list, which reaches `rig daemon start` and `/healthz`, with either setting: "Couldn't verify whether another daemon has database <db> open (<reasons>): ownership is unknown, not confirmed clear. Startup and database writes continue", plus, under `stop`, that it stops only on a confirmed conflict.

`lsof` now reports why it had no answer (not installed, timed out, or failed); exit status 1 stays a clean "nothing holds this". A matching record whose listener doesn't answer, times out, or gives an answer that can't be read (the body aborts, or a 200 isn't JSON) is unknown. A holder whose own files can't be read is unknown unless a record confirms it. Definite answers stay non-conflicts: a record for another PID or database, an unreadable record, and a listener answering for a different PID. The stop is unchanged: `daemon.db_conflict=stop` still stops only when an open-file holder, a matching `daemon.json` and a listener reporting that PID agree, and the confirmed-conflict path, its message and `--allow-db-conflict` are byte-for-byte as before. getting-started.md gains one sentence. Known limit: a recorded listener answering non-200 still adds no warning, as before.

Credit: Calvin Edwards (@calv-io-n) found that an inconclusive check reads as no conflict, in factory-level/plantworks#114; that change refused the start on an unknown result, and this one says so instead.

Validation: daemon-db-conflict.test.ts runs the real createDaemon to its first database open with fake lsof and fetch: missing, timed-out and failing lsof, a slow or unreadable listener answer, and an unreadable holder each warn in both modes without stopping; clean boots add no warning; the existing confirmed-conflict, go-ahead and cross-home cases are unchanged. 17/17 there and 7/7 in daemon-db-path.test.ts; the independent review's 9 controls pass. Cost: source +43/-13, tests +75/-2, docs +3; no new refusal, stop, setting or code.
2026-10-24 23:36:22 -07:00
Mike Schwarz 47ce581743 fix(capture): say when a pane is too small to show the prompt the seat is at (#1969)
When a seat waits at an approval or question prompt and its tmux pane is smaller than a standard terminal, the prompt can be cut off, so `rig capture` showed only the bottom of the menu ("1. Yes 2. No") and whoever was looking couldn't see what the seat was asking. Now `rig capture` and the MCP capture tool check whether the captured screen is at a permission or selection prompt, and read the pane's size only then. Below 80x24 the result carries `promptPane: { width, height, hint }`, and the CLI prints the hint on stderr, for example: pane 18x12 is smaller than 80x24, so the prompt may be cut off; resize it (tmux resize-window -t =<seat>: -x 120 -y 40) and capture again. The command includes the daemon's tmux server flags when it doesn't use the default server, and targets `=<seat>:`, the seat's own session's current window, so a same-named window in another session is never resized.

Other capture callers are unchanged and make no extra read: send verification, walk, the previews and attention recovery. There's no polling and no new setting, and `rig capture --help` describes the note.

Validation: the tests alone fail on main for the small-pane note, the route asking for it and the CLI stderr note, while the no-change cases pass (a normal-size pane, a shell in a small pane, a caller that doesn't ask). A real private-tmux control, with a window named like the seat in another session, failed on the first head (it resized that other window) and passes now, including for a seat name with a space and an apostrophe. 312/312 across the capture, tmux-server and session-transport daemon files and 19/19 in the CLI capture tests.
2026-10-24 23:35:27 -07:00
Mike Schwarz 95a3880486 fix: first-start one materialized seat without restoring siblings (#1965)
A team imported with `--materialize-only` could not start one ordinary seat: launch entered restore and reported “no startup context.” Retain each materialization’s declaration in the existing startup record, then let positional `rig launch <rig> <seat>` use the shared validated-spec launch ingress for that never-occupied seat. Resolve it with current saved settings and policy provenance, reporting changes from the declaration. Other seat records and IDs stay intact; successful first start captures context for later restore.

Cost and limits: one retained declaration per materialization, no migration or setting. Source files must remain available. Present or indeterminate targets and consequential races stop with distinct remedies; ordinary metadata edits do not refuse. Old materializations are not retrofitted. Explicit snapshot/subset launches remain restore; `--plan` remains restore selection.

Validation: the unchanged topology-ingress pin fails before the final correction and passes afterward, alongside all 17 HTTP/SQLite first-start controls. Daemon typecheck and daemon/CLI/TUI builds pass. The local full daemon run had 1,171 passing files, 39 failing and 4 skipped; after fixing missing local builds and temp/tmux environment prerequisites, all 39 failed files passed one qualification (210 checks). The latency control passed separately at 43.4 ms against its unchanged 250 ms budget. The original full run remains red; this is not a single clean full-suite pass. Fake runtime/tmux adapters cover authored startup and preservation; native provider readiness is untested.
2026-10-24 22:43:34 -07:00
Mike Schwarz 64d11426e6 feat(cli): MCP worker custody: rig_queue_handoff passes held work on in one step, rig_queue_unclaim puts it back (#1970)
An agent that reaches OpenRig through MCP (a desktop app, or an assistant on `rig mcp serve --http`) could claim and close an item but couldn't hand it on, so passing work along meant close-and-create, which loses the link a delegator follows. Two work tools now call the daemon's existing routes, the same ones `rig queue handoff` and `rig queue unclaim` use. `rig_queue_handoff` closes the item as handed off and opens its successor in one transaction, with lineage in `chainOfRecord`; the task's text, tags and priority carry over unless given, and a note, summary and person-route `evidenceRef` are optional. `rig_queue_unclaim` returns an in-progress claim to pending for the same seat with its reason recorded. Both are annotated destructive.

Scope for an authenticated caller matches `rig_queue_update`: the item's owner must be in scope, and a handoff's next seat too, so it never reaches a seat outside the grant or a person. A timeout says the outcome is unknown and to check `rig_queue_show` before retrying; a repeat after it landed answers `qitem_already_terminal` and creates nothing. The server instructions gain one sentence, and the openrig-user skill, its digest and the capability map's MCP sentence are updated (36 tools, 19 work tools). Not included: cross-host handoff, `targetRepo`/`gate`, `fallback`, hosted grants, the 2026-07-28 protocol and principal attribution.

Cost: source +58/-1, tests +92/-10 in 3 files (about 1.6 test lines per source line), docs +15/-9. No new refusal, error code, route, setting or dependency. Validation: 70/70 across the four MCP queue/HTTP/server/operations files and 9/9 in the other server-building MCP files; 8 of the new cases fail against main's `mcp-server.ts`. The independent security review ran 74/74, including real HTTP grants whose owner and destination scope checks each fail when removed. HTTP fixtures use in-memory SQLite and inert delivery, not native desktop-client or remote deployment proof.
2026-10-24 22:39:31 -07:00
Mike Schwarz 5f28fdb606 fix(terminal): keep seat tiles through session replacement (#1964)
An open rig view keeps a seat’s tile when its tmux session is replaced. The shared command waits up to 30 seconds for the same session name and managed node identity, then reattaches by exact tmux ID. It checks the name returned by tmux before reading identity, so a missing target cannot select a sibling. Removed or absent identity markers keep one-shot attachment. Herdr reuse and TUI/CLI copy guidance read the separate short recovery command.

Cost: one shell per tile, with metadata polling only while disconnected. A deliberate detach takes about two seconds to close; a replacement delayed beyond that live-session grace can require reopening. Close and reopen views created before this change. No watcher, setting, schema or provider-specific recovery mechanism.

Validation: real private tmux reproductions cover four-seat replacement, removed identity, a missing target during replacement, and disappearance before opening; each fails before its corresponding fix. The last two exposed the CI failure without increasing any timeout. All 458 affected terminal contract and CLI window checks plus the daemon typecheck pass locally at this revision. Earlier round checks also covered TUI rendering and CLI/TUI typechecks; those were not repeated for this shell-only correction. Tests use the actual stop adapter/composer because the scenario runner has no terminal-view action. Native Herdr/cmux/Rex apps and SSH were not run.

Addresses #1939. Native Herdr, cmux and Rex and real SSH were not run, so the issue stays open for the reporter to confirm.
2026-10-24 22:23:22 -07:00
Mike Schwarz 43378f5c87 docs(transport): state where Claude draws a dialogue as natively checked, for the layouts exercised (#1968)
## What this changes

Two comments, and no logic. They now say how much of the quoted-prompt veto's (#1955) premise has been observed natively.

The veto treats prompt-looking text above Claude's empty input box as quoted output, but only when Claude's own timer row runs directly above that box. That rests on one fact about Claude's screen: a real dialogue replaces the input box, rather than being drawn beside the running timer row.

## Why

#1955's comments stated that fact outright and pointed to "the native captures here". One of those fixtures, `claude-permission-2.1.300`, holds transcribed menu excerpts rather than full screens (its `.md` says so), so it can't show where a dialogue sits. #1955's PR body was corrected at the time, with the claim labelled an inference until a native check.

## What the native check showed

The check ran on Claude Code 2.1.309, on #1955's fix and on a build without it:

- **Protected dialogues:** the MCP enable menu, a Read permission dialogue and an AskUserQuestion picker each replaced the input box, and each refused a send.
- **Parallel calls:** this included a turn with two Read calls issued in parallel. Twenty sampled frames show the waiting dialogue, and none shows it beside a hook timer row.
- **Fix versus no fix:** quoted selector text, and a permission phrase in a running Bash command, refused a send and a queue wake without the fix, and were delivered with it. That held in the fresh running-hook window too.

**Not covered:** other Claude versions, macOS, Codex (not run), and any layout the run didn't exercise. The comments say the observation covers the exercised layouts, not every version.

## The changes

- **`session-transport.ts`, the timer-row comment in `classifyPaneActivity`:**
  - states the dialogue geometry as natively observed on 2.1.309, including parallel Read calls;
  - scopes it to the layouts exercised.
- **`claude-quoted-prompt-veto.test.ts`, its header:**
  - the same statement;
  - names which fixture shows geometry: the 2.1.289 picker is a native screen. The 2.1.300 permission menus are excerpts, so they test only the menu text.

Comment lines only: +12/-6. No code, test assertion, matcher, refusal or setting changes.
2026-10-24 22:02:11 -07:00
Mike Schwarz 971e57eaed docs(skill): say what rig skill audit's stale and hash findings mean, and which read-only check answers "can I refresh this?" (#1966)
## What a user gets

An agent that sees `stale`, or two copies of one skill with different hashes, in `rig skill audit` learns what that means before it refreshes anything:
- **What the audit is:** a date and hash finding, not a judgment of whether the copy is safe to refresh.
- **What answers "can I refresh this?":** the existing read-only check, `rig skill loadout` without `--apply`, and what each of its answers means.

It also learns where nothing can answer: a global or untracked copy has no install record. So it doesn't overwrite a copy whose history is unknown, and doesn't treat every difference as a deliberate fork.

## The change (text only)

**`rig skill audit --help`** now says:
- **What the audit reports:**
  - declared provenance;
  - whether a verified date is within 90 days and names an evidence source (`stale_verified` and `bare_verified`, which are state `stale` in `--json`);
  - the content hash;
  - whether a copy is byte-identical to a bundled plugin skill.
- **What it doesn't:** whether a copy is safe to refresh.
- **For a managed copy in a working folder, the read-only check:** `rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json`, without `--apply`. Its answers:
  - `current`;
  - `stale`: still OpenRig's last projection, so `--apply` can refresh it;
  - `conflicting`: read the detail. It can also mean the target couldn't be inspected, so any difference is unknown, and a confirmed difference doesn't prove a deliberate edit;
  - `shadowed`: read the detail;
  - `missing`.
- **`rig skill loadout --seat <session|self> --json`** (0.7.1 and later) lists a seat's own skills and where they're stored, without classifying them.
- **A global or untracked copy:** its origin stays unknown. Ask the skill's owner, and don't move global skills into a project just to get a record.

**The audit's human output** gains one closing line, on PASS and FAIL: the audit doesn't say whether a refresh is safe, plus the read-only `loadout` command and a pointer to `--help`.

**`docs/reference/project-workspace.md`:** one paragraph in its managed-skills section says the same.

No new check, refresh, classification or record.

## Checked against the source

- **`packages/daemon/src/domain/skill-audit.ts`:**
  - **freshness:** the 90-day window (`FRESHNESS_WINDOW_DAYS`); `stale` is set for `stale_verified` or `bare_verified`.
  - **content hash:** `hashSkillFolder` hashes the folder's top-level files.
  - **bundled match:** requires the plugin's exact file set and bytes.
  - **refresh state:** the audit reads no ownership record and classifies none.
- **`packages/daemon/src/domain/skill-catalog.ts`, `classifySkillProjectionTarget`:**
  - **`current` / `shadowed`:** an equal digest, owned or not;
  - **`stale`:** an owned target still at the prior projection, "can be refreshed safely";
  - **`conflicting`:** a different digest, and also the `catch` when the target can't be inspected;
  - **`shadowed` also covers a kept copy:** a same-named unowned skill, or an owned plugin copy edited after projection, as its detail says.
- **The read-only `--seat` inspection** (`inspectSeatSkills` in `seat-skill-apply.ts`) lists skills, storage and delivery without per-copy statuses. Those come only with `--apply`.
- **`--seat` is first in 0.7.1:** it's in the 0.7.1 line and not in 0.7.0's `skill.ts`.

## Proof

- **RED:** the two new cases in `packages/cli/test/skill.test.ts` fail on main's `skill.ts`.
- **GREEN:** the file passes, 6/6.
- **Not run locally:** the CLI typecheck, which CI covers.

## Cost

- **Source:** +21 lines in one file, help text plus one output line.
- **Tests:** +21.
- **Docs:** +18.
- No new option, setting or refusal.
2026-10-24 21:29:34 -07:00
Mike Schwarz bc0dd81ce2 fix(restore): grant the generic post-compact-extra.md and the configured restore files a managed restore reads (#1962)
## What a user gets

A managed Claude restore reads the generic `post-compact-extra.md` without stopping at a Read approval. The same holds when the compaction
settings move the extra instruction file or the restore maps somewhere else, when the configured file is a symlink, and when the setting
has stray spaces around it.

## Why it happened

#1940 grants a seat's restore reads as `Read(//…)` allow rules. It covered the per-seat `<home>/compaction/post-compact-extra/<seat>.md`.
When a seat has no per-seat file, the restore request names the generic `<home>/compaction/post-compact-extra.md` instead (the default
`policies.claude_compaction.message_file_path`), and nothing granted it. A managed restore on a Claude seat with OpenRig's core plugin
stopped there at an approval; one approval let it finish.

The two compaction settings that move these files had the same gap:
- `message_file_path`, when set to another file;
- `preparation_root`, where restore maps go when it's set.

## The fix

- **The generic file is always granted.** The restore request names it even before it exists, so whether it exists at launch says
  nothing about the restore.
- **The configured extra file and `<preparation_root>/<seat>/*/RESTORE-MAP.md` are granted too.** Both Claude launch paths (fresh or
  fork, and resume) take an optional `restoreReadPolicy`. The daemon resolves it at each launch from the same compaction settings the
  restore uses. Without it, the grants are as before plus the generic file.
- **The configured file is granted as the restore request names it.**
  - It goes through the same trim as the restore's resolver, one shared helper, so a padded setting grants the file the request names.
  - The whole file, not only its directory, is resolved, so a configured file that is itself a symlink has its target granted too.
  - The grant is still that one file at its two spellings.
- **Every rule is at both spellings,** the resolved path and the real path, as in #1952.
- **Still not granted:** the session transcript, which the restore request names only when both the restore marker and a restore map are
  missing.
- **Not covered: a relative `message_file_path`.** The restore request names it as written, relative, while the grant resolves it under
  the daemon's working directory, which can differ from the seat's. Such a seat still gets an approval, so it fails safe. An absolute-or-empty
  check like `preparation_root`'s would close it, separately.
- **A test pin:** `s03-bound-launch`'s check of `startup.ts` text now expects the new constructor line.

Source: +42/-8 in five files (`managed-restore-read-access.ts`, `claude-compaction-enforcer.ts`, both Claude adapters, `startup.ts`).

## Tests

- **New:** the generic file, a configured extra file and a configured preparation root are each granted at both spellings, and nothing
  else in those directories or another seat's maps is; both Claude launch paths grant the configured extra file the restore will name.
- **A parity test** renders the restore request the enforcer actually sends, and checks every file it names against the granted rules,
  where it's named and where it resolves. It runs for the default settings, a configured extra file and preparation root, a configured
  file that is a symlink, and a padded setting. So a restore file added later without a grant fails here, not at a native approval.
- **#1940's exact-list cases** now expect the generic file.

RED, the tests alone on main (`c061d705`, run 40151781330): exactly 8 tests fail, all in `managed-restore-read-access.test.ts`. They are
the six launch-shape exact-list cases and the two new tests. Nothing else fails.

GREEN at `f6566d9b` (run 40154339522): all 8 jobs pass.

Round 1 RED, the parity test alone on `f6566d9b` (`de3ebe30`, run 40157449038): exactly the symlinked-file and padded-setting cases
fail; the default and configured cases pass, and nothing else fails.

Round 1 GREEN at `4f6b804b` (run 40157573273): all 8 jobs pass.

The managed restore case that stopped at the approval still needs a native rerun after merge.
2026-10-24 20:59:25 -07:00
Mike Schwarz 3fd34f2db4 fix(watchdog): the parked-owner reminder waits a grace after a seat starts waiting (#1963)
The parked-owner watchdog reminds a seat that is idle at its prompt while it holds open queue rows. It ran on its next 120 s tick, so a seat that had just handed work to a peer, or just asked a person a question, got "You are parked …" within a couple of minutes of ending its turn. It then spent a turn on the reminder, or re-asked the person.

**What changes**
- The parked diagnosis reports `activity.since`: when the seat's arbitrated state last changed (its activity, or its needs-input count, reason or evidence rung). For a stopped seat, that's when its current waiting state began.
- The parked-owner consumer skips a seat whose waiting state has been unchanged for less than 5 minutes, recorded as `waiting-grace` in the evaluation notes. A seat still idle with its rows after that is reminded on the next watchdog tick, exactly as before. If the start isn't known, the reminder goes as before.
- A seat at an approval or question prompt gets no grace. The consumer's refused attempt there is what starts the prompt-blocked alert to whoever can clear the prompt, so that alert is not delayed.
- The reminder now tells a seat waiting on a person's answer not to ask again, and to hold the row with `rig queue block <id> --on <who> --wake-after <duration>` (a hold on a person also needs `--summary` and `--evidence-ref` unless the row has them).
- A held row with a live wake still doesn't count as parked, so that hold keeps a long wait quiet.
- There's no new setting. Both cases in the report end the seat's turn, so one default covers them.
- `docs/reference/seat-delivery.md` describes the grace and the prompt exception.

**Tests**
- RED, the first tests alone against main: 3 failures.
  - a seat that started waiting 30 s ago is reminded on that tick;
  - the grace doesn't reach the consumer through the shipped diagnosis;
  - the diagnosis has no `since`.

  The cases that keep today's behaviour pass on main too: the reminder after the grace, and the reminder when the start is unknown.
- RED, the prompt test against the first fix: a seat stopped at a prompt 30 s ago was skipped instead of attempted (1 of 37).
- GREEN: 247 of 247 across the 11 daemon files that build the parked consumer or call the diagnosis.

Addresses #1933.
2026-10-24 20:57:09 -07:00
Mike Schwarz cd09c0ed63 feat(codex): put OpenRig's activity hooks in hooks.json when the Codex home has one (#1957)
## What a user gets

Some Codex users keep their hooks in `hooks.json`. Before this change, OpenRig always added its activity hooks as an inline block in
`config.toml`, so their Codex home mixed both formats and Codex warned about it at startup. Now, when a Codex home has a `hooks.json`,
OpenRig puts its activity hooks there and moves its own old inline block out. A Codex home without a `hooks.json` gets exactly what it got
before.

Your own hooks, their order, their trust and your disables stay as they were. This covers OpenRig's own block only: if you keep your own
inline hooks in `config.toml` beside `hooks.json`, they stay where they are, and Codex may still warn about that mix.

## How it works

- **OpenRig's groups:** one group per activity event (`SessionStart`, `UserPromptSubmit`, `Stop`, `PermissionRequest`), marked with
  `statusMessage: "OpenRig activity"`, which is also what Codex shows while the hook runs.
  - An existing OpenRig group is replaced in place.
  - Otherwise the group is appended after your groups, so your groups keep their positions, and Codex's trust is keyed by position.
- **Recognising OpenRig's group:** only its exact authored shape counts. That's one handler with only OpenRig's fields, its timeout and its
  label, running the activity relay from where OpenRig ships it (this install or an earlier one). A group of yours that runs your own
  script, even one with the same file name and label, is left alone, and OpenRig adds its own.
- **Trust:** written only for OpenRig's groups, keyed by the real path of `hooks.json`. Your groups are never trusted on your behalf.
- **OpenRig's old inline block** in `config.toml` moves out. If you had disabled one of its hooks in Codex (`enabled = false`), the disable
  moves to the hook's new `hooks.json` key.
- **Turning hooks off** (`runtime.codex.hooks_enabled: false`) removes only OpenRig's groups. Codex keys trust and disables by position, so
  when a removed group sat before yours, your later groups' trust and disables move with them to their new positions.
- **Both files are checked before anything is written.** The proposed `config.toml` must parse and equal the current one plus exactly the
  planned fields. Otherwise neither file changes, and a warning says so. The same applies to a `hooks.json` that can't be read as hooks.
- **Per-seat homes:** a seat with its own `CODEX_HOME` uses that home's `hooks.json`.

## Credit

fsafey/openrig#7 found this problem and handled more cases. The disable migration and the trust re-keying on removal here follow the same
idea. Not carried: a warning when your own inline hooks remain beside `hooks.json`. Your hooks are left where they are.

## Tests

`codex-activity-hooks-json.test.ts` covers:
- OpenRig's groups added after yours, and trust for exactly them;
- the old inline block moved out, while your inline hooks stay;
- idempotence, and a new relay path replacing OpenRig's group in place;
- a malformed `hooks.json` left alone;
- removal;
- a seat's own `CODEX_HOME`;
- no `hooks.json`, which gives today's block.

**Round 1 adds:**
- a disabled inline hook moving disabled, with the enabled case as a control;
- removing OpenRig's group from between two of yours, so the later one keeps its trust and disable;
- a quoted `"trusted_hash"` that would have become a duplicate key, which now leaves both files untouched;
- your own relay with OpenRig's file name and label, which is kept, and OpenRig's group is added after it.

Round 1 RED, the tests alone (`7a6b2e81` on `9f4cbe98`, run 40151455871): exactly the four new tests above fail; the enabled control
passes, and nothing else fails.

Round 1 GREEN at `30069c00` (run 40155470675): all 8 jobs pass. The first fix head, `50550a57` (run 40151457584), failed two removal
tests. Its check before writing compared a planned `config.toml` holding an empty `hooks.state` table against text that had dropped the
table with its last entry, so it refused and left OpenRig's groups in place. `30069c00` compares the two without an empty table, which
means the same as none.

Thanks to @fsafey, whose PR in their fork found the mixed-format problem and showed where trust is keyed.
2026-10-24 20:34:18 -07:00
Mike Schwarz 864368a1e0 fix(queue): retry prompt-refused wakes when attention clears (#1961)
## What a user gets

A queue wake refused at an approval prompt retries once when observed attention clears to work or idle, instead of waiting for the stuck sweep. Hook updates and changes in existing pane captures use the same activity resolver as status; the prompt refusal and all delivery checks remain.

## How you verified it

Baseline: three missed-retry assertions fail; the existing sweep control passes. With the change: 114 checks pass across focused queue, outbox, structural activity, wake-ladder and startup suites, including a real hook update through the assembled queue and a split correlated return whose uncertain sibling stays outside the clear retry. General recovery of that sibling remains covered. Daemon typecheck passes. No native model run; tests use real SQLite and domain code with simulated transport results and pane frames. Required CI is pending.

## Anything you were unsure about

Only pending prompt-refused wakes qualify. Existing outbox claims preserve the frozen notification and exclude delivered, uncertain, retained or active sends; legacy nudges retain their existing path. Unknown observations do not clear attention, and a retry refused again stops until another observed clear or the existing backstop. No new timer, schema, reason code, or extra capture. Cost: +51 net source lines, +144 test lines (2.67 added test lines per added source line), a small session set and one destination-scoped recovery pass per observed clear. A clear the daemon never observes still relies on the sweep.
2026-10-24 20:17:05 -07:00
Mike Schwarz 4e6896506a fix(slack): every gateway post names its sender first; a wake-rung escalation names the seat it's waiting on (#1960)
## What a user gets

In a Slack channel where many agents post, the person sees who is asking before the ask. Every gateway post now starts with its sender, `From <seat> on <machine>`:
- as the first block, above the headline;
- as the first line of the fallback text a notification shows.

Before, it was the last block and the last line.

A wake-rung escalation (the queue's last rung, which posts a stuck row to a person) now says which seat the work is waiting on: `Waiting on <seat>: <the row's summary>`. Before, it showed only the row's summary, often with no seat named at all.

## The change

- **`slack/message.ts`:**
  - the sender line moves to the front, in both the blocks and the fallback text, and now reads `From …`;
  - it's still named once, with the same caps and the same fallback budget;
  - at Slack's 50-block limit the repeat note is still the block left out. It's now found by identity, since it's no longer the second block.
- **`operator-delivery-engine.ts`:**
  - the escalation's summary names the seat the row is waiting on;
  - its sender is unchanged: the row's own. So a reply in the thread still goes where the row's posts go;
  - a row with no sender is still signed with the queue's place. The stuck seat didn't send the escalation, so it isn't shown as the sender.
- **Not in this change:** a separate "what you need to do" line. No row field holds a one-line ask (`humanIntent` is `decision` or `update`, and `humanDetail` is free-form), and this change adds no parsing.

## Tests

- **New** (`slack-sender-first.test.ts`):
  - the sender is first, and named once, for a full session, for a session plus its machine, and with no seat;
  - the 50-block limit drops the repeat note, never the sender or the ask;
  - the escalation headline names the waiting seat, with and without a row sender, while the sender stays the row's own.
- **Fail-first:** all 6 new tests fail on main.
- **Existing tests that pinned the old order** (the sender last, the body at block 1, a lowercase `from`) are updated to the new order in five files.
- **After the change:** all 80 Slack, gateway and delivery test files pass (1,186 tests), and daemon `tsc --noEmit` is clean.

## Docs

`docs/reference/slack-app-setup.md` now says each post starts with its sender.
2026-10-24 20:02:51 -07:00
Mike Schwarz 3470c8cf5c fix(queue): a stalled-after-claim finding's age is the stall's own, not the claim's (#1959)
## What a person gets

Take a row that was claimed, parked (blocked) for an hour, then resumed with `rig queue update --state in-progress`. If it then went quiet, the stuck sweep could report "stalled-after-claim on <row> (77 min)". In fact it had been active for about 7 minutes, and the finding's own "why" line said so: "no meaningful queue change for 7 min".

Now the headline and the body's `age:` line count from the same moment as the stall decision, and round the same way, so all three agree to the minute.

## Why it happened

Line numbers are at `c3c514f2` unless marked "this head".

- **Which clock the age used:**
  - A stalled candidate's age came from the claim time: `ageMinutes: minutesSince(row.claimedAt, now)` (`queue-stuck-sweep.ts:467`).
  - That age is printed in the summary (`:743-745`) and in the body's `age:` line (`:400-406`).
- **Why that clock included the park:**
  - `claimed_at` is written only on claim (`queue-repository.ts:2570`, `:2579`).
  - The generic state update that resumes a parked row (`:3165-3173`) doesn't touch it, so the claim time still counts the parked hour.
- **What the decision already used:**
  - The stall decision itself anchors on the latest of claim, last meaningful change and post-claim heartbeat (`queue-pickup.ts:98`), and its evidence floors the minutes (`:101`).
  - `minutesSince` rounds instead (`queue-stuck-sweep.ts:308-312`). So the headline could also differ from the "why" by a minute even without a park.

## The change

- **The age:** for stalled-after-claim candidates only, `ageMinutes` now comes from the candidate's existing `evidenceAt`, which is the same anchor as the decision. It's floored by a small `wholeMinutesSince` helper (`queue-stuck-sweep.ts:316`, this head), and assigned at `:478` (this head).
- **What doesn't change:**
  - which rows are judged stalled;
  - `evidenceAt` / dedup / recurrence;
  - routing and `claimed_at`;
  - every other finding kind's age.
- **Two regression tests** in `queue-stuck-sweep.test.ts`:
  - claimed → parked → resumed → quiet: the summary and body count minutes since the resume, not since the claim;
  - a 30 min 40 s stall reads 30 in the headline, the body and the "why" alike.
- **Limit:** a finding that's already open keeps its stored text. Only findings created after this change get the corrected age.

## Overlap

#1313 ("distinguish input waits from stalled claims") edits `queue-pickup.ts` and `queue-stuck-sweep.ts` too, but not this age. This change touches only the sweep's helper and that one assignment, so whichever lands second rebases.

## Checked

- **Tests:**
  - With the fix: all 16 test files on the sweep and pickup paths pass on this head, 373 tests.
  - Without the fix: against the old `queue-stuck-sweep.ts`, exactly the two new tests fail (102 pass).
- **Review:** an independent review found nothing to change. It confirmed `evidenceAt` is the same anchor as the decision, and that the age stays a display field read only after the stall decision. It ran `queue-stuck-sweep.test.ts` on an export of this exact head (104/104).
- **Merge:** it merges cleanly with current `main`.

## Cost

One helper, one assignment, two tests (+48/−4). Display only.
2026-10-24 19:32:24 -07:00
Mike Schwarz 5f2260c927 fix(daemon): an in-place Codex restart reports a pane it restarted as restarted (#1958)
## What a person gets

`rig seat restart --keep-conversation` restarts a Codex seat in place. When that restart can't prove the resumed conversation, the result used to end with this:

> The original Codex exited, but the resume was not proven: Codex resume could not be proven within the resume window: … The session was preserved; nothing was restarted.

The last sentence was false: the pane had restarted. It now reads:

> The pane restarted: the original Codex exited and the resume started in the same pane, but it wasn't proven: Codex resume could not be proven within the resume window: <reason>. Check the pane with rig capture <seat>.

- **Same fix, second case:** an in-place restart whose records name a different conversation (disproved, not just unproven) gets the same correction.
- **`account.startupGate`** no longer carries the false sentence either.
- **Other paths, unchanged:** every other caller of the adapter, where nothing was restarted, keeps its existing wording, which is true there.

This follows up #1950, where both readers noted the sentence. #1950 fixed one cause of an unproven resume, the home lookup; this corrects the message for every other cause.

## The change

Wording only. There's no change to proof logic, timing, launch, recovery or outcomes.

- `codex-runtime-adapter.ts`: the unproven and disproved results also carry the detail without the closing sentence, in a new optional `resumeDetail`. The existing `error` text is unchanged.
- `runtime-adapter.ts`: that optional field, with a doc comment.
- `seat-restart-in-place.ts`: the in-place `incomplete()` result uses `resumeDetail` and says the pane restarted. `remainingStep` is unchanged.
- `seat-restart-in-place.test.ts`: two cases, unproven and disproved, driven through the adapter and the restart service. The result says the pane restarted and never says "nothing was restarted", and the adapter-level error keeps its existing text for the other callers.

**Launch and resume is an artery** (`docs/as-built/arteries.md`). Only text changed: nothing that decides proof, timing or recovery reads the new field.

## Checked

- **Tests:** the three test files that cover these messages pass in full on this head: `seat-restart-in-place` 106/106, `provider-sign-in-fact` 30/30, `codex-rebuild-permissions` 13/13. Against the old source, exactly the two new cases fail and the other 104 pass.
- **Review:** an independent review found nothing to change. It checked:
  - that both callers of the in-place result come after the original process was stopped and the resume started in the same pane;
  - that earlier stop and respawn failures keep their existing paths;
  - that the new field has no consumer that makes a decision.

  It also ran the in-place file on an export of this exact head (106/106).
- **Merge:** it merges cleanly with current `main`.
- **Not done:** no live seat was restarted for this.

## Cost

Message text, one optional field, and two tests (+38/−5).
2026-10-24 19:10:26 -07:00
Mike Schwarz 43ff956262 fix(queue): say how to cancel a row in update's help and in the closure refusal (#1956)
## What a person gets

`rig queue update --help` lists `canceled` both as a `--state` value and as a `--closure-reason` value. So the natural way to cancel a row looks like this:

```
rig queue update <id> --state canceled --closure-reason canceled
```

The daemon refuses that with `closure_fields_not_admitted`, and until now nothing said what to type instead. Now:

- **The help says it.** The description gains "To cancel a row: --state done --closure-reason canceled." The `--closure-reason` text now reads "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."
- **So does the refusal.** The `closure_fields_not_admitted` message ends with "To cancel it: --state done --closure-reason canceled." The error code, the HTTP 400 and the CLI's exit code 1 are unchanged.

## The change

Text only. There's no change to which forms are accepted.

- `packages/cli/src/commands/queue.ts`: the `queue update` description and the `--closure-reason` help.
- `packages/daemon/src/domain/queue-repository.ts`: one sentence added to the refusal message.
- Tests:
  - `packages/cli/test/queue.test.ts`: the help says how to cancel a row;
  - `packages/daemon/test/sweep-a-closure-coherence.test.ts`: the refused form's message names the working form.

The wording is deliberately narrower than "closure fields go only with `--state done`". That wouldn't be true: parked rows, handoffs and supersessions also carry closure fields.

## Checked

- **Tests:**
  - With the fix: both changed test files pass in full (daemon 7/7, CLI 165/165).
  - Without the fix: both new tests fail against the old source.
  - The built CLI's `--help` shows the new text.
- **Review:** an independent review found nothing to change. It checked the wording against the closure validator and the existing plain-cancel and supersession tests, and ran both new tests.
- **Merge:** it merges cleanly with current `main`.
- **Credit:** reported by the QA lead and a team lead, who both hit the refused form while closing rows during a release check.

## Cost

Help and error text only, plus two tests.
2026-10-24 18:53:42 -07:00
Mike Schwarz db863940c8 fix(transport): quoted approval text stops vetoing a send to a working Claude seat (#1955)
## What a user gets

A send or queue wake to a Claude seat that is plainly working is no longer refused as "at an approval prompt" just because the seat quoted approval-prompt text in its own output, such as a selector like `› 1. Yes, proceed (y)` or "Do you want to proceed?".

Real approval dialogues stay protected: the permission dialogue, AskUserQuestion, and the MCP server menu.

## The defect

`classifyPaneActivity` looks for prompt signatures in the last 12 non-blank lines: numbered selectors, plus phrases such as "Do you want to proceed?". Quoted text matches as well as a real dialogue does.
- **The ordinary pane probe:** a quoted selector reads as `selection_prompt`. This is pre-existing and was observed on a working seat.
- **#1864's fresh running-hook path:** this path narrows selectors to the current dialogue, but still scans the permission phrases across the whole window, so a quoted phrase reads as `permission_prompt`. This was found by reading the source and hasn't been seen on a real seat.

Either way the send is refused with `target_needs_input`, and delivery stalls until someone notices.

## The fix

One condition in the existing detector, `session-transport.ts` +9/-2.
- **When it applies:** for a Claude read, when Claude's own timer row (for example `✶ Churning… (12s · …)`, including the `running … hook` form) runs directly above its complete, empty input box. The box must be framed, with a recognised mode bar.
- **What it does:** the turn is running and no dialogue is open, so the prompt scan ignores the text above that box.
- **Only the timer row counts.** A selector-shaped "mid-work" line doesn't, so a prompt whose selector sits just above the box isn't mistaken for work.
- **What stays protected:**
  - A dialogue that replaces the input box never meets the condition, because no empty input box is at the bottom. The native AskUserQuestion capture (`claude-question-picker-2.1.289.json`) shows that layout.
  - **Correction (provenance):** an earlier version of this body cited the 2.1.300 permission fixture as showing this. It doesn't establish screen geometry: `claude-permission-2.1.300.md` says those are ConPTY menu excerpts with separators removed, not full-screen captures. No retained full-screen native capture shows where a Claude permission dialogue sits, or one beside a running hook timer.
  - **So this is an inference [I]:** "a real permission dialogue never shows beside Claude's running timer row above an empty input box". That includes parallel tool calls, where one tool's hook timer runs while another asks permission. It's for Quality's native check. The latest PermissionRequest hook still overrides first (`classifySendReadiness`).
  - A prompt drawn above the input box (`send-prompt-guard`'s FORWARD-FIX layout) has no timer row, so it's still refused.
- **Unchanged:** Codex, other runtimes, and reads without a runtime (the display surfaces).

## Limits, deliberately conservative

These still read as attention, as today:
- **an idle Claude seat** (no timer row) with quoted prompt text above its input box. Nothing on screen tells that apart from a prompt drawn above the box;
- **a draft in the input box;**
- **a mode bar outside the recognised list,** such as the default "⏸ manual mode on";
- **an unframed input box.**

## Tests

`packages/daemon/test/claude-quoted-prompt-veto.test.ts`, +120.

**The classifier:**
- quoted text reads as working with a timer row, in both modes;
- the native permission (Bash, Edit, WebFetch), AskUserQuestion and MCP dialogues, and a prompt above the input box with a listed phrase, all read as attention under every option set;
- with no runtime, a Codex runtime, no timer row, an unrecognised or non-mode bar, or a draft, the result is identical to before.

**The real send path,** `SessionTransport.send`:
- delivered for the working seat with quoted text, through both the ordinary probe and the fresh running-hook path;
- refused at a real permission dialogue and at the MCP menu, through both paths.

**Results on Build:**
- **Before the fix,** the new tests against the base source fail 3 of 12, on exactly the working-seat cases. The ordinary probe refuses on the quoted `› 1. Yes, proceed (y)` (`selection_prompt`), and the fresh-hook path on the quoted "Do you want to proceed?" (`permission_prompt`). The protection cases pass on both sides.
- **With the fix,** 1,723 tests pass across 40 files: every test file that refers to the classifier, the send probe or `target_needs_input`. That includes `send-prompt-guard` (FORWARD-FIX 1), `claude-question-picker` and `session-transport`. The daemon builds, and its typecheck exits 0.

## Cost

Source +9/-2, tests +120. No new refusal, matcher, error code or setting.
2026-10-24 18:49:00 -07:00
Mike Schwarz f8b3d03cd1 fix(compaction): show undeliverable managed restore stages (#1953)
## What a user gets

Managed Claude compaction shows the owed restore stage and its last delivery result in `rig compact --state` and seat status. Active-work idle waits show as waiting, without marking the stage held or raising a failure notice. Repeated actual failures create one queue item for the delegation owner or registered human fallback; changed reasons update it without another wake.

## How you verified it

Baseline RED exposed missing diagnostics; round-one RED reproduced false busy-stage failure counts and a notice callback running inside the real lifecycle lease. After correction, 85 focused daemon tests pass, including three busy-stage polls, unresolved/rejected callbacks, retry, restart, cancellation and notification deduplication. Daemon typecheck passes. Initial author checks also passed 210 daemon and 63 CLI tests. The stub runtime cannot perform native Claude compaction; these fixtures do not establish the original native stall's cause. Required CI will run on the new head.

## Anything you were unsure about

Notices start after the guarded operation settles and do not delay its drain. The human fallback is a plain ask: urgent-only subscribers are not paged; every-ask subscribers keep their usual notification. Cost: one small record write per unsuccessful poll and at most one diagnostic item per attempt (+124 net source lines, +149 net test lines; 1.09 added test lines per added source line). Existing owner-closed dispositions stand; an open notice is not automatically closed when the stage later progresses. No new reason codes, scheduler or delivery permission; idle, occupant, typing, retry and cancel behavior stays.
2026-10-24 18:44:10 -07:00
Mike Schwarz c061d70530 fix(restore): grant managed restore reads where each file resolves, not only as named (#1952)
## What a user gets

A managed Claude restore on a Mac reads its own restore files without stopping at a Read approval, the same as on Linux. The same applies on
any machine where the OpenRig home or the seat's project is reached through a symlink. One gap remains on every platform and is fixed separately: the generic `post-compact-extra.md` fallback isn't in the grant yet, so a restore that reads it still asks once.

## Why it happened

#1940 grants a seat's restore reads as `Read(//…)` allow rules built with `path.resolve`, which keeps symlinks. Claude Code's
[permission docs](https://code.claude.com/docs/en/permissions) say allow rules "apply only when both the requested path and the file it
resolves to match". On macOS, `/tmp` is `/private/tmp`. So a read of the compaction packet under `/tmp/claude-compaction-restore/…`
resolves to `/private/tmp/…`, which no rule covered, and the restore stopped at the approval. #1940's native A/B ran on Linux, where `/tmp`
isn't a link, so it couldn't show this.

## The fix

Each base directory (the OpenRig home, the seat's working directory and the packet root) now gives two spellings, and every restore file
is granted at both:
- the resolved path, as before;
- the real path, with symlinks followed.

A directory that doesn't exist yet, such as the packet root before the first compaction, keeps its remaining path under its nearest real
parent. Where nothing on the way is a link, the two spellings are equal and the rules are exactly as before. The grants still cover only
the seat's own files. `managed-restore-read-access.ts`: +34/-10.

## Tests

- **New:** a symlinked home, project and packet root (the packet root not yet existing) are each granted at both spellings, and every
  grant stays inside the fixture.
- **#1940's exact-list test** now expects both spellings. On a Mac runner that includes `/private/tmp/…`.

RED, the tests alone on main (`69a2eb76`, run 40141826010): on the macOS runner, #1940's six launch-shape cases fail because nothing is
granted under `/private/tmp` (the bug itself), and the new symlink test fails too. Nothing else fails.

GREEN at `12d84fa2` (run 40141827784):
- attempt 1: every job passed except one daemon test file, `rex-transport.test.ts`'s timing case, unrelated, which also flaked on another
  branch tonight; all the restore-read tests passed;
- attempt 2: all 8 jobs pass.

A native check on a Mac seat is still needed.
2026-10-24 18:43:02 -07:00
Mike Schwarz 54864d331f test(rex): wait for a timed-out run's orphaned child to be reaped instead of checking once (#1954)
## What this fixes

`rex-transport.test.ts` › "ends a run that does not answer in time, with its process group" failed intermittently on macOS CI runners (for example run 40141827784, attempt 1). The failing assertion was the last one: an instant check that the script's backgrounded `sleep` was gone (`expect(() => process.kill(child, 0)).toThrow()`). The deadline and timing assertions before it passed.

`stopGroup` returns once the run's process group is empty and the direct child is reaped. The orphaned `sleep` is reaped by the system on its own schedule, so on a loaded runner it can still be listed for a moment after the run settles. An empty pid file would also have read as pid 0, and `process.kill(0, 0)` signals the test's own process group.

## The change (test only)

- **The orphan check:** wait up to five seconds for it to go (`expect.poll`), instead of reading the process table at one instant.
- **The pid:** assert the pid read from the file is a real one (an integer above 1).
- **The deadline:** one second instead of 300 ms, so the script's start fits well inside it on a loaded runner.
- **The elapsed bound:** under two seconds, which is the deadline plus `stopGroup`'s one-second grace. It still fails if the deadline stops ending the group at once.

No product code changes.

## Proof (local, macOS)

- **The test alone:** 8 of 8 repeated runs pass, and the whole file passes 24 of 24.
- **Without the immediate group kill at the deadline:** the test fails (2,050 ms against the 2,000 ms bound).
- **Without the deadline:** the test fails (it times out at 20 s).
2026-10-24 18:30:23 -07:00
Mike Schwarz c3c514f2a0 fix(proof): watch mission inputs instead of the workspace tree (#1951)
## What a user gets

Proof-source monitoring recursively watched the workspace parent, including unrelated checkouts and build trees. It now recurses at the configured missions root and uses filtered shallow watches for other policy and evidence inputs. Related to #1813.

Replaced roots and input parents get rebound. Failed or unsettled coverage reports unavailable, including a missions alias resolving outside its project. Canonical readers, default behavior and judgment publication checks are retained.

## How you verified it

At 424337a7, implementation and independent QA each passed all 52 tests across proof-source-boundary, proof-source-root, proof-policy-reuse and proof-judgments. Daemon typecheck passed. Added regressions failed on their preceding implementations, including both outside-root cases before the final correction.

The checks exercise error latching, named/null replacement events, new missions after rebind, bounded reconciliation, state-change notifications, containment and publication/recovery controls. Replacement and alias callbacks use mocked delivery over real temporary files. Full repository build, test and lint were not run.

## Anything you were unsure about

Unavailable means uncertified live coverage, not filesystem isolation: a watch requested at a configured symlink can still follow its real target. Large missions trees remain recursive. Native large-tree behavior, performance and the reported freezes have not been reproduced by these checks. Startup-lock behavior remains a separate investigation.

- [x] One concern per PR; no version bump; no CHANGELOG.md edit
- [x] Tests added or updated where the change is testable
- [x] Checks run, results and omitted checks listed
2026-10-24 18:03:20 -07:00
Mike Schwarz 97df5d529d fix(seat-restart): prove an in-place Codex resume in the home the running Codex uses (#1950)
## What a user gets

`rig seat restart --keep-conversation` no longer reports that the resume wasn't proven, and that nothing restarted, for a Codex seat that
did restart and resume. This happened whenever the seat's Codex ran on a `CODEX_HOME` the restart's proof didn't look in.

## Why it happened

The in-place restart reads the running Codex's `CODEX_HOME` from the process itself, and refuses to relaunch into any other home. But its
resume proof (`verifyResumeInPane`, `sendResumeCommand`) passed the adapter only the seat's node. The adapter then read the home the seat
declares in `env:` (#1937), and otherwise the daemon's. A Codex whose home came from anywhere else wrote its records where the proof never
looked:
- a home from a one-off `--env`;
- a home set on the tmux session or in the shell;
- on a build without #1937, any `env:` home.

So the proof found no record, and the report said "not proven ... nothing was restarted", although the pane had restarted and resumed.

## The fix

The restart passes the home it already read to the proof. `verifyResumeInPane` and `sendResumeCommand` take it as an optional last argument,
and when it's given it wins over both the declared home and the daemon's. Callers that don't pass it behave exactly as before. Source: +14/-11
in two files.

## Tests

- **`seat-restart-in-place.test.ts`:** in both pane shapes, shell-backed and direct, the restart passes the running Codex's home to the
  proof, though the seat declares none.
- **`codex-seat-home.test.ts`:** an explicit home wins over the seat's declared home and over the daemon's, for both methods.

RED, the tests alone on main (`b15a00ad`, run 40140393992): exactly the two new tests fail. GREEN at `a66263fb` (run 40140395642): all 8
jobs pass.
2026-10-24 17:49:49 -07:00
Mike Schwarz 9bb29d3881 fix: renew owned Codex plugin content approvals on upgrade (#1948)
## What a user gets

Upgrading OpenRig no longer stops a working Codex seat at "Hooks need review" for OpenRig's own unchanged plugin. Codex 0.164 fingerprints the whole plugin, and this release changed openrig-core's scripts and skills while its six hook declarations stayed the same, so every upgraded Codex seat stopped. OpenRig now re-approves its own plugin on upgrade, without asking, when three things hold:
- the earlier approval exactly matches the previously shipped plugin;
- nothing is disabled;
- the installed files exactly match what shipped.

## The change

- **The renewal:** a new `codex-owned-plugin-trust.ts` plans it, and only when all three conditions hold.
  - "Previous" is OpenRig's recorded approval, else the pinned published 0.1.17.
  - Disables and choices in any config layer or profile keep native review.
  - It writes only `trusted_content_hash` under the six OpenRig-owned hook headers, plus one small receipt (mode 0600) per Codex home.
- **Checked before writing:** the proposed config is parsed and must equal the original plus exactly the planned fields. Otherwise nothing is written, no receipt is recorded and native review stays. This covers quoted keys and table text inside multiline strings. The final re-read before the write is kept.
- **Install versus approval:**
  - Install eligibility keeps the previous check: the shipped hook files and the Codex manifest. So a home with extra non-hook files still gets the upgraded plugin.
  - The first-install declaration grant and the renewal both require the exact shipped tree, so an unmatched tree is never approved automatically.
- **Main only:** renewal stays in the selected Codex home (#1937).

**Unchanged:** explicit disables, refusals, custom config layers, other people's hooks, and Codex's own baseline. Codex records no "continue without trusting" choice, so a deliberate skip of this exact owned update is overridden. That was the accepted choice.

## Behaviour by case

| Case | What happens |
|---|---|
| Clean shipped tree | A fresh install gets the six exact declarations plus content approval. An owned upgrade renews. |
| An extra non-hook file in the plugin folder, fresh home | The plugin installs. Nothing is approved automatically, so Codex's own review applies. |
| An extra non-hook file, approved upgrade | The new plugin installs. The existing approval stays as it was, with no renewal. |
| A changed shipped hook file or Codex manifest | The Codex plugin isn't installed or upgraded. |

## Proof

- Three review rounds, each read by an independent Claude and Codex pair:
  - a config edit that could make a valid file unloadable;
  - the install check, which had widened to skip upgrades for any extra file;
  - the first-install grant on an unmatched tree.
- The focused suites pass, 179 of 179 on main, with each round's failing control red first. Daemon typecheck is clean, and required CI passes at this head.
- Native upgrade validation on real Codex homes is the release quality lead's, outside this PR.

## Cost

+353/-19:
- source +189/-14 (the adapter +48/-14, the new module +141);
- tests +158/-1;
- docs +6/-4.

No new setting. It adds whole-tree reads on Codex install checks and one receipt per Codex home.
2026-10-24 17:32:11 -07:00
Mike Schwarz 4626668ac3 Add a GitHub Sponsors link (#1949)
Adds `.github/FUNDING.yml` with one line, `github: mvschwarz`, so GitHub shows its standard Sponsor button on the repository page, linked to github.com/sponsors/mvschwarz.

Nothing else changes: no README, docs or site edits. The repository's Sponsorships setting is switched on separately.
2026-10-24 17:21:15 -07:00
Mike Schwarz dd3d061d1b rig provider switch help says what it does today (#1947)
## What a user sees

`rig provider --help` and `rig provider switch --help` said the command switches a seat to an account, with "interruption-safe account switching". It doesn't do that yet. On main, the daemon's `switchAccount` (`provider-service-impl.ts:158-168`) runs the precheck and always answers `failed_safely`:
- **Without `--force-unsafe`:** the answer carries the precheck's reasons.
  - A manual precheck assumes a live conversation, because live state isn't read yet. So the reasons always include `would_strand_live_conversation`.
  - `target_auth_unknown` is added for an unknown target, and `rebind_unsupported_for_runtime` for a Claude target.
- **With `--force-unsafe`:** the precheck's refusal is skipped, and the answer is `switch_execution_not_yet_wired`.

No account changes either way. The help now says so, including that the plain output prints only the outcome and the reasons are in `--json`.

## The change

Three help strings in `packages/cli/src/commands/provider.ts`, +3/-3:
- the `rig provider` description;
- the `switch` description;
- the `--force-unsafe` description. It used to say the switch "still refuses to strand a live conversation". When forced, though, the service skips the precheck entirely, so that was true only because nothing executes.

There are no behaviour changes. No doc or skill repeats the claim: the only other mention, `docs/as-built/cli-reference.md:764`, lists the flags without their descriptions. No test pins the old text.

## Cost

Help text only. No tests change.
2026-10-24 16:39:23 -07:00
Mike Schwarz 7d263ea3d6 fix(status): a seat waiting at a prompt only the pane shows needs attention, on every surface (#1946)
## What a user gets

A seat waiting at a prompt that only its pane shows (for example, a Codex seat at an approval prompt that no hook reported) now reads as
needing attention, with its reason, on every surface: the seat API, the rig and TUI count, the `rig ps` table and the compact JSON. Before
this, the row served `needs_input` (`selection_prompt`, from the pane reading) while `needsAttention` was false, so the table said
"working, —".

## Why it happened

Since `rig ps` renders the served attention flag (#1876), that flag has to agree with the activity the row serves. It didn't:
- `attachTerminalActivityAndWork` derived `needsAttention` from a fresh hook-store read, not from the served `agentActivity`, which also
  reads the cached pane observation when the hook is absent or stale;
- the rig count read only the hook store.

## The fix

- **One step, two readers.** The hook-or-pane step of the activity ladder moves, unchanged, into `hookOrPaneActivity`:
  1. a positive hook keeps its meaning;
  2. a current usage limit stays;
  3. otherwise, the cached pane reading answers.
  `attachAgentActivity` calls it and applies motion as before.
- **The per-seat flag** reads the served `agentActivity`, and falls back to the hook store where activity wasn't attached.
- **The rig count** reads the same step, with the pane-reading service wired into it, so it can't disagree with the row.

**Unchanged:**
- The arbitrated signal still comes first, so an observed clear from an authoritative rung wins.
- Stale hooks still read `unknown`.
- 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.
- Everything stays capture-free.

## Tests

`node-attention-consumers.test.ts` drives the real seat route, the rig count, the CLI (JSON and the table) and the TUI reader from one
fixture. It now adds:
- **The native case:** a prompt only the pane reading sees gives ATTN with `selection_prompt` in the table and the compact JSON.
- **The same case after a stale hook,** and **after a daemon restart** with no arbitrated state yet.
- **Controls:** an observed clear still wins, and a current running hook still outranks the pane reading.

CI: RED (the tests alone on main, run 40140037040) fails in `package-tests (daemon)`, as intended. Required CI (8/8) passes at this head. The same change is the 0.7.1 successor candidate `2f6f1f49` (patch-ids checked), and its own RED/GREEN ran the same way (40140033718 / 40140035418).
2026-10-24 16:37:51 -07:00
Mike Schwarz 431f2dc52f fix(seat-home): one-off --env runtime homes aren't applied, and startup's Claude readers follow the seat's home (#1945)
Follows #1937 and closes two of the gaps it listed as still daemon-scoped.

## What a user gets

- **A one-off `rig up --env CLAUDE_CONFIG_DIR=…` or `CODEX_HOME=…` isn't applied, and the CLI says so.** That value used to put the first
  launch on a home that a restore, a handover and `rig seat status` didn't know about: the seat ran on one login while status reported
  another, and the next restore moved it back. The note says to declare the home in `env:` in the rig spec. Other `--env` values work as
  before.
- **For a Claude seat with its own config dir, activity and continuity read that dir.**
  - Activity: the self-report rung finds the seat's `pid.json`.
  - Continuity: a policy that watches the seat's transcript, such as a size threshold, finds the file.
  Before, both looked only in the daemon's dir and found nothing for such a seat.

## Changes

- **CLI** `up.ts`: leaves `CLAUDE_CONFIG_DIR` and `CODEX_HOME` out of `--env`, with a note.
- **Daemon** `node-launcher.ts`: drops both from any one-off env it receives (`withoutRuntimeHomes`), so an API caller gets the same result.
- **Daemon** `startup.ts`: the Claude self-report reader and the continuity transcript lookup resolve through `claudeConfigRootFor`, in this
  order:
  1. the seat's declared home;
  2. the daemon's `CLAUDE_CONFIG_DIR`;
  3. `~/.claude`.
  A seat that declares no home reads exactly what it read before.
- **Docs:**
  - `rig-spec.md`: the `--env` exception, and "name the home in `env:`";
  - the `openrig-user` recipe step 2 says the same, with its edge digest updated.

## Still daemon-scoped, on purpose

Live usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. Its readers are built once with the daemon's
homes, and reading each seat's home would need a native reader per home. `rig-spec.md` now says so.

## Tests

- `seat-runtime-home.test.ts`:
  - `withoutRuntimeHomes` drops both names and keeps the rest;
  - `claudeConfigRootFor` returns the seat's home, else the daemon's, else `~/.claude`.
- `seat-env-journey.test.ts`: a one-off home reaches neither the seat's terminal nor its forwarded names; other one-off values still do.
- CLI `up.test.ts`: `--env` with both homes and `DEBUG=1` sends only `DEBUG`, and prints the note for each home.

GREEN at `cceedfb1` (run 40131620044): all 8 jobs pass. RED, the tests alone on main (`e9eb005d`, run 40131618183): still queued; I'll add
its result here when it lands.
2026-10-24 15:54:47 -07:00
Mike Schwarz c3c96c3a6f fix(daemon): recognize Codex footers whose model name carries a reasoning label (#1944)
## What a user gets

`rig up --existing`, and any other resume of a Codex seat, recognizes the resumed conversation when the Codex status footer shows a model name with a reasoning label, for example:

```
  GPT-6.1 Sol medium · /data/workspaces/proof-a · …
```

Before this change, once Codex's "OpenAI Codex (v…" header had scrolled off, that footer wasn't recognized. The resume then exited 1 with "Codex resume has not yet reached an interactive conversation" while Codex was running normally. Footers such as `GPT-6-Astra high` already worked: one word after the version was allowed, but a display name plus a reasoning label is two.

## The change

- The model field in a footer may now hold up to three words after the versioned id. That covers a display name and a reasoning label. It applies to both the `gpt-*` field check and the versioned non-`gpt` field, which is still used only when a Codex process is verified.
- Everything else that makes a line a footer is unchanged:
  - an indented, ` · `-delimited status row whose whole field is the model;
  - a composer line above it;
  - trust, update and model-selection gates stay authoritative.
- A longer run of words that starts with a model name is still prose, not a footer, so a status or conversation line can't pass as a ready footer.

## Tests

- **New cases:**
  - the reported footer;
  - the model field in a later position (`Context 81% left · GPT-6.1 Luna xhigh · /project`);
  - a verified non-`gpt` model with two words;
  - two prose negatives: a sentence that starts with `GPT-6.1 Sol`, and a field with four words. Both still read as returned to the shell.
- **Before the fix:** the three positive cases fail, and the negatives pass.
- **After the fix:**
  - `native-resume-probe`, its evidence tests, `codex-runtime-adapter` and `codex-resume` pass (333 tests), and daemon `tsc --noEmit` is clean;
  - the other test files that import the probe pass, as does `session-transport` (270).

## Cost

- Source: 2 regular expressions changed, `{0,3}` in place of `?` for the trailing words, plus the comment (+5/−3).
- Tests: +17.
- There's no new refusal or code, and no runtime cost.

Reported by @george661 on #1764. Thank you.

**Review note:** the longer field follows the shape Codex renders (an optional capitalized display word, one effort label from Codex's closed set, an optional tier word). It's a shape check, not proof against every prose line: a line like `GPT-6.1 default settings · /project` would still match.
2026-10-24 15:41:54 -07:00
Mike Schwarz ccabb30a84 fix(pi-runner): send each block as a prompt the runtime queues while busy (#1932) (#1943)
For #1932.

## What a user gets

A Pi or OMP seat whose startup sends more than one block no longer raises `runtime_error` ("Agent is already processing") for the second
one. The second block reaches the running turn as a steer.

## Why it happened

`RunnerCore.handleUserBlock` chose between `prompt` and `steer` from its own `streaming` flag, which turns on only at `agent_start`. A block
that arrived after a `prompt` had gone out, but before `agent_start` came back, went out as a second `prompt`. A busy agent refuses that,
and OMP reports the refused input as `runtime_error`.

## The fix

The runner no longer decides. It sends every ordinary block as `prompt` with `streamingBehavior: "steer"`, and the runtime does the rest:
- **When the agent is idle,** the prompt runs.
- **When it's busy,** the prompt is queued as a steer: delivered after the current turn's tool calls, before the next model call.

The window the runner couldn't see is covered too:
- **Pi** counts itself busy from the moment the first prompt's preflight succeeds.
- **OMP** counts itself busy from when a prompt starts.

So a block sent before `agent_start` is queued behind that turn instead of refused. Pi's RPC `prompt` has accepted `streamingBehavior`
since 0.32.2, and OMP's RPC `prompt` accepts it too.

**Why no runner state:** a prompt can succeed with no turn. An extension command or an input handler can consume it in Pi, and OMP reports
`agentInvoked: false` for a consumed builtin. A runner-side "busy" mark would then send later work as `steer` to an idle agent, where it
only queues and never runs. With no runner state there's nothing to get out of step. The runner's `streaming` flag now only labels the
pane's mirror line.

**A side effect:** a `/command` typed mid-turn now goes through `prompt`, so Pi runs an extension command immediately instead of refusing it
as a steer. `pi-runner.ts` is +7/-10 against main.

## Tests

- **Pi and OMP:** the reported order (prompt sent, success response, a second block before `agent_start`) sends two prompts with
  `streamingBehavior: "steer"`, and OMP raises no `runtime_error`.
- **After a local-only command succeeds with no turn,** real work still goes as `prompt` and never as `steer`:
  - Pi's success with no turn;
  - OMP's `agentInvoked: false`, in its response and in a later `prompt_result`.
- **The existing routing tests** (idle, mid-turn, after `agent_end`, rapid submissions) now expect the new `prompt` shape. The mirror line
  still says "you (steer)" mid-turn.

GREEN at `c744bac4` (run 40132196534): all 8 jobs pass. RED, the round-1 tests alone on the previous head (`dca2c44e` on `e205a6e4`, run
40132193843): still queued; I'll add its result here when it lands. The first round's RED, the tests alone on main (`d6e2d92c`, run
40127969014), failed the two reported-order tests.

Thanks to astral-fleet-sessions for the report, which traced the order exactly.

**Compatibility:** a Pi before 0.32.2 ignores `streamingBehavior`, so a mid-turn block would be refused there as on main's second prompt. OpenRig can't launch such a Pi, because its Pi launch needs `--name` (added in 0.78.0).
2026-10-24 15:33:11 -07:00
Mike Schwarz 97b0dc17e4 Say when Claude plan mode will answer on Sonnet for the bare haiku alias (#1942)
## What a user sees

A Claude Code seat configured with the model alias `haiku` and the saved `plan` permission answers its plan-mode turns with Sonnet. That's Claude Code's own plan-mode behaviour: it upgrades the literal alias `haiku` to Sonnet in plan mode, and a full model id keeps its model. Until now nothing in OpenRig said so; the seat showed `haiku` and answered on Sonnet.

With this change, for that one pairing:

- **The launch result** (`rig up`, `rig seat launch`, a fork or a resume) and **the restore result** carry one line:
  `<seat>: Claude Code answers plan-mode turns with Sonnet when the model is the alias "haiku". To keep Haiku, set a full Haiku model id: rig seat set-model <seat> --model <full Haiku id> --reason "<why>".`
- **`rig seat status`** prints the same line under "Permissions, planned for the next fresh managed launch", right after the summary. `--json` has it at `permissions.account.advisories`.
- **`docs/reference/getting-started.md`** ("Per-seat permission mode") gets one sentence saying the same.

**This is disclosure, not an automatic model-selection fix.** OpenRig still passes the configured `--model` and `--permission-mode` unchanged. It doesn't pin an alias to a dated id, add a refusal, or change any launch flag. Choosing the full id is the user's call, and the message names the command for it.

## The change

- `planModelAliasNotice(runtime, { model, permissionMode }, seat)` in `adapters/yolo-mode.ts`, beside `claudePostureFlag`. It returns a note only for `claude-code` with mode `plan` and a model that is exactly `haiku` after trimming, which mirrors Claude Code's own exact check.
- It's called beside the existing non-interruptive notice in `StartupOrchestrator` (fresh, resume and fork) and in `RestoreOrchestrator`'s Claude resume, and from `planPermissionAccount`, which reads `nodes.model`, the same value the launch binds.
- The CLI's `SeatStatusResponse.account.advisories` is optional, so an older daemon that doesn't send it prints as before.
- **Shipped config:** OpenRig ships no spec, built-in team, skill or doc that pairs `haiku` with plan mode, so nothing changes there. The one bare `haiku` that ships is the Claude usage probe (`claude-token-usage.ts`). It runs in print mode with no permission mode, so it's never in plan mode, and the alias keeps it on the latest Haiku.

## Cost

- Source +30/-3 across 5 files, docs +4, tests +129.
- One SQLite read of `nodes.model` per `rig seat status` permission account. Nothing on the launch path except a string check.

## Tests

- **Seat status** (`permission-account.test.ts`): the advisory appears for `haiku` and ` haiku ` with plan. There's none for `claude-haiku-4-5`, `opusplan` or `sonnet` with plan, and none for `haiku` with `acceptEdits` or no saved mode. The native argument is unchanged in every case.
- **Launch** (`startup-orchestrator.test.ts`): the warning is on the result for claude-code `haiku` + plan. There's none for a full id, no mode, or a codex seat, and the binding passed to the adapter is unchanged.
- **Restore** (`plan-model-alias-notice.test.ts`): the restore result carries the warning for `haiku` + plan, and none for a full id or `acceptEdits`. The resume still receives the configured model and mode.
- **CLI** (`seat.test.ts`): the advisory prints on the line after the summary.

**Results on Build:**
- **Before the fix,** against unchanged source with these tests, 10 cases fail: 9 daemon cases and 1 CLI case. The no-note cases pass.
- **With the fix:** daemon 170 passed (1 existing skip), CLI 53 of 53, `tsc` exit 0 for both the daemon and the CLI, and `check-first-run-references` reports 0 failures.

**Still to check live:** a native plan-mode turn on a seat set to a full Haiku id, to confirm it answers on Haiku. This PR doesn't run a model.

**Review notes:**
- `rig seat status --json` now always carries `permissions.account.advisories`, which is empty for every other pairing. The human output and launch arguments are what stay unchanged.
- Claude Code normally answers plan-mode turns for `haiku` with Sonnet. Its source keeps Haiku when every Sonnet is excluded by a model restriction.
2026-10-24 14:54:11 -07:00
Mike Schwarz 3817662158 fix: allow seat-scoped managed restore reads on Claude launch (#1940)
A Claude seat with limited Read permissions can finish compaction and then stop at approval for OpenRig's restore marker outside its working directory. Launch and resume now merge narrow Read allowances into the existing session settings: the seat's marker and generated map, the two restore/refocus skill files, its extra-instruction file, and five packet files under the native conversation UUID passed at launch. Fresh, fork and both resume paths use the same composer; forks use the child's UUID. Runtime-switch successors use the canonical seat name rather than their temporary tmux staging name.

Permission mode, existing settings/hooks and native deny/ask rules are preserved. No shared settings or restore writers change. Cost: the next launch/resume adds up to eleven Read patterns, including when managed compaction is disabled; existing running sessions do not acquire them. Operator-selected preparation roots, arbitrary referenced sources and a packet root overridden only inside the seat keep their ordinary permission checks. A legacy name without a native UUID gets no packet-directory allowance. Known limit: packet rules stay bound to the launch token. If an accepted resume rotates T1 to T2, or the native conversation changes later, a packet fallback read may still ask; the seat-keyed marker, map, extra and skill allowances are unchanged. This does not grant every conversation's packets.

Validation: four command-builder regressions failed before the fix; 270 focused tests pass after it, including shell-executed argv capture, default/explicit-mode launch and resume, fork identity, runtime-switch staging, exact generated Read-rule scope, settings preservation and idempotence. Daemon TypeScript passes. The existing 36-cell interactive launch stub also passes. Stubs cannot establish Claude's native Read matching or a completed compact/restore journey; that independent native check is still pending.
2026-10-24 14:27:09 -07:00
Mike Schwarz 953ce6d3b7 fix: explain terminal tile recovery after fresh seat replacement (#1941)
When a fresh seat replacement detaches an open terminal tile, the launch result now names the seat and gives commands to reopen its rig view in Herdr/tmux or cmux. The existing warnings appear in human and JSON output, including a failed startup after the old session was stopped. Clean first launches get no replacement warning.

This is recovery guidance: the person rebuilds the view manually. Every confirmed fresh replacement gets the guidance, even if no view was open; it does not count open workspaces or automatically reattach tiles. Other replacement verbs keep their current reporting. Provider code, detach safety and lifecycle decisions are unchanged.

Validation: the missing-warning cases failed before the fix; all 68 fresh-launch tests pass after it, including terminal/Claude replacements, failed startup and a clean-launch control. Daemon build/TypeScript passes. All 52 CLI lifecycle tests pass, including human/JSON warnings on both success and error responses. Desktop providers were traced in source; no native desktop run or live seat changes.

Refs #1939. Thanks @vanducng for the terminal-seat reproduction.
2026-10-24 13:51:28 -07:00
Mike Schwarz 5864069915 feat: a seat can run on its own Claude or Codex account (#1937)
A seat can now run on its own Claude or Codex account. Each runtime keeps its login in its home: Claude Code in `CLAUDE_CONFIG_DIR`, Codex in `CODEX_HOME`. A seat whose member or rig `env:` declares an absolute one runs on that home for the launch and lifecycle paths below, instead of the daemon's:

```yaml
pods:
  - id: dev
    members:
      - id: impl
        runtime: codex
        env:
          CODEX_HOME: /Users/me/.codex-second-account
```

The home is prepared once, natively (`CODEX_HOME=<home> codex login`, or `CLAUDE_CONFIG_DIR=<home> claude` then `/login`). OpenRig copies no credentials or history between homes.

## How

`packages/daemon/src/domain/seat-runtime-home.ts` resolves a seat's home from its stored `env:` (member over rig). A value that isn't an absolute path is reported and ignored. It doesn't reach the runtime: `getEffectiveSeatEnv`, the one source for launch, restore, handover and runtime switch, leaves it out. So the seat resolves as it did before, and nothing is refused.

| Runtime | What follows the seat's home |
|---|---|
| Claude | the managed launch's exported `CLAUDE_CONFIG_DIR`; the first-run bootstrap (`.claude.json`, onboarding and trust); resume capture (`<home>/sessions/<pid>.json`); the fallback resume probe, which runs with the seat's config dir only when one is declared |
| Codex | the launch, resume and fork commands; OpenRig's managed `config.toml`, hooks, feature flag and core plugin; the profile, daemon-support and network-default probes; thread capture, including on adoption and discovered handover (by node); the resume proof, including `rig seat restart --keep-conversation`'s |
| Codex, legacy paths | the legacy resume and restore pass the home only when one is declared, so other seats' calls are unchanged |
| Readers | context usage and the model reader also search declared homes (thread ids are unique across homes) |
| Usage limits | Claude seats with their own home pool apart (`claude:home:<dir>`) from `claude:local`, and the reactive tap skips a Codex seat with its own home, so one account's exhaustion isn't charged to another |
| Status | `rig seat status` shows the runtime home and where it comes from (member, rig, daemon or native default), and any ignored value |
| Provider | `rig provider status` lists the seat's own home as its configured account (`homeFrom`, `ignoredHome`); the daemon home's auth registry no longer binds a seat that has its own home |

Codex homes are passed as a parameter, not adapter state, so concurrent launches for different seats can't cross.

**Still daemon-scoped (not every reader follows the seat yet):** startup's Claude self-report evidence and its continuity transcript lookup still read the daemon's root, and native usage refresh stays daemon-scoped. A one-off `rig up --env CODEX_HOME=…` value still reaches the panes without this resolution.

**A correction to how this worked before:** `CLAUDE_CONFIG_DIR` is one of the managed Claude launch's own keys, so a seat's declared value was never forwarded to Claude, even when the daemon set none. `rig-spec.md` is updated, along with the Codex line.

**Compatibility:** the provider record's new fields are optional, so an older daemon still accepts this one's seat accounts across hosts.

**What doesn't change:** seats that declare no home; shared homes (several seats may name one); a running seat, which keeps its home until its next launch.

**A known limit:** a conversation saved under a seat's earlier home isn't in its new one, so it can't be resumed there. OpenRig doesn't copy it between homes.

## Tests

- **First round:** RED on main `7e69abc1` fails the 21 new tests ([run 40118004305](https://github.com/mvschwarz/openrig/actions/runs/40118004305)). GREEN at `2524764f` passes all 8 jobs ([run 40118018519](https://github.com/mvschwarz/openrig/actions/runs/40118018519)).
- **Review fixes:** RED, with the tests alone on `7b2065f3`, fails exactly the 9 round-1 tests ([run 40125339648](https://github.com/mvschwarz/openrig/actions/runs/40125339648)). GREEN at `77f70a2e` passes all 8 jobs, with 21,151 daemon tests ([run 40127630330](https://github.com/mvschwarz/openrig/actions/runs/40127630330)).
- **Covered:**
  - the resolver: member over rig, ignored relative values, nothing declared;
  - the Claude launch, bootstrap, resume capture and fallback probe;
  - the Codex fresh, resume and fork launches with managed config in the seat's home;
  - thread capture on adoption and handover;
  - the resume proof and in-place restart;
  - legacy resume;
  - context usage;
  - usage pools by home;
  - status and provider accounts;
  - unchanged seats.

These tests use fixture executables only and never sign in. Real logins in distinct homes, and the same conversation across resume, restore and handover, still need a native run.
2026-10-24 13:34:02 -07:00
Duc 676d40788c feat(terminal): add Rex as a terminal provider (#1935)
Rex as a terminal provider, by @vanducng. Thank you. The scope is in #1934.

## What a user gets

From a [Rex](https://www.superlogical.com/rex/docs) pane, `rig terminal open <view> --window` and the TUI open the team's view as a new Rex session, one window per page with the same 16-tile grid as Herdr, and switch the Rex app to it. `--provider rex` selects it explicitly. Outside Rex nothing changes: the default order stays Herdr, then cmux.

## The change

- **The provider:** `rex-adapter.ts`, `rex-transport.ts` and `rex-layout.ts` implement the existing `TerminalProvider` seam over Rex's JSON CLI, passing `-S` because this Rex build ignores `REX_SERVER`. Herdr's grid builder moves into a shared `tile-layout.ts`, with Herdr's layout output unchanged.
- **Selection:** the CLI (`terminal-window.ts`, `terminal-window-rex.ts`) and the TUI (`open-window.ts`) pick Rex when called from inside Rex. Inside a Herdr pane, Herdr keeps the view even when `TERM_PROGRAM=rex`, and the CLI and TUI now agree on that. A window opened from inside Rex is refused unless this terminal and the daemon use the same Rex server.
- **Status stays fast:** discovery gives each of Rex's version and status calls 1 s (the pair races a 1.25 s deadline), shares one probe per status report, and the service probes Herdr, cmux and Rex side by side. A stalled Rex reads "did not answer within 1000 ms" while the other providers report normally. Mutations keep their 30 s budget.
- **Uncertain outcomes stay uncertain:** a Rex call that times out, is killed, or returns an unreadable reply is `unknown`, not `failed`. A create whose reply is lost, or names no session, returns `rex_outcome_unknown` and says to check Rex before retrying. A lost window reply keeps the session, names the unconfirmed pages, and never calls `session.destroy`. The session is closed, as before, only when every window definitely failed.
- **Docs and skills:** `terminal-sessions.md`, `getting-started.md`, the as-built adapters page, and the `rigs` and `openrig-herdr` skills.

## Proof

- **CI at this head:** all jobs pass, tested merged with current main. The daemon's terminal-service, rex-adapter and rex-transport suites include a never-settling Rex beside a healthy Herdr (the report returns in under 5 s, using one probe's two 1 s calls), lost create, lost first-page and lost later-page replies (no destroy), and the Herdr-inside-Rex precedence on both the CLI and TUI paths.
- **The author's native runs** (Rex `0.0.1792787366`, API revision 7, two Macs): status, a 4-seat open on each Mac, `--window` switching the app, the TUI preview and open over HTTP, a 17-seat two-page view, and a stalled-Rex measurement at an earlier revision. These are the author's evidence. Our review read the source and CI only.

## Limits

- Not run on Linux.
- Rex counts a block's login-shell startup toward its quick-exit window, so tiles set a 10 s threshold and the adapter checks for exited tiles once, about 2 s after opening. A tile that fails between 2 s and 10 s keeps its error on screen, but the result lists that seat as opened.
- `rig terminal status` with no provider also probes Rex, bounded as above.

## Cost

Source +721/-111, tests +993/-13, docs and skills +82/-20. One new result code (`rex_outcome_unknown`), no new setting.
2026-10-24 13:04:20 -07:00
Mike Schwarz e69ede7a49 fix(proof): proof add finds a slice in another catalogued project (#1938)
Found by the lab's onboarding self-scout. Thank you.

## Problem

`rig proof add <absolute slice path>` refused with "resolved to … but no parent mission was found" for a slice in a catalogued project other than the configured one, until `OPENRIG_WORK_ROOT` named that project. `proof add` resolves its missions root once, from `--workspace`, then `OPENRIG_WORK_ROOT`, then the configured `workspace.slices_root`. `findSlice` then looks for the owning mission only inside that root. The read side already selects a catalogued project: `proof show`/`judge --project`, and queue work since #1526.

## Fix

`proof add` calls `findSlice` as before. Only when that refuses an **absolute** path, and no tree was named with `--workspace` or `OPENRIG_WORK_ROOT`, it:
- picks the catalogued project whose root holds the path, with the existing `inferCatalogProject` (the rule `context work-install` uses for a working folder: the deepest containing root);
- reads that project's missions root with the existing `readProjectMissionsRoot`;
- tries `findSlice` there.

If that also fails, the original refusal is reported unchanged. There's no second resolver and no new refusal or setting.

## Proof

- **RED** (`41a784e0`, tests only, on main `dc9feb34`): the slice in the second catalogued project is refused with "no parent mission", the reported error. The two controls pass on main too, as they should.
- **GREEN** (`024978de`):
  - `proof`, `proof-project-flag` and `scope-fs` pass 111 of 111;
  - the CLI `tsc` is clean.
- **The three cases:**
  1. the drop lands in the other project's slice, and nothing is written to the configured project;
  2. an explicit `--workspace` still decides, so a slice outside it is still refused;
  3. a slice in no catalogued project is still refused.

## Cost

Source +33/-3, tests +67.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 12:24:28 -07:00
Dery Ferdika 4584223fe8 docs(test-system): correct the CI scenario job's counts in the two places that state them (#1925)
## What a user gets

The two documents that describe the CI scenario job now agree with the job's own script. The job has grown from the two seeded cases it shipped with: its arrays select eight scenarios across fourteen container runs, the library holds twenty scenarios beside its seven stub topologies, and seven of those library scenarios run in CI. A reader preparing a change can now use either document to predict what the CI job will run, which matters because picking a case is how you prove a change before pushing it. The page's own out-of-date list also drops the entry that flagged the README, since the flagged section is the one this change corrects.

## How you verified it

Tested on `e4d7f15a` with the change on top. The counts come from `scripts/run-pr-scenarios.sh` itself rather than from a reading of the docs: `CASES=(fixture library)` with `MODES=(healthy lost-baton healthy)` is six runs, `EFFECT_CASES=(refocus-parked)` with `EFFECT_MODES=(healthy seeded healthy)` is three, and the five `PASSING_CASES` (transcript, capture, daemon-restart, refocus-active, refocus-parked-off) run healthy once each, which is fourteen containers across eight scenarios. The container runner's own case table (`packages/test-system/ci/result.mjs`) is what maps each case name to its scenario file, so the rewritten sentence names the seven library scenarios and the fixture case by their scenario names rather than by case keys. The scenario count is the twenty non-stub YAML files in `packages/test-system/scenarios/` beside the seven `*stub*` topology files. `node scripts/check-docs-guard.mjs` passes, so the tracked-paths rules under `docs/` are satisfied, and `npm run mirror-skills --check` passes. What I could not run: the container job itself, which refuses to run outside GitHub Actions without an explicit `--remote` executor, so the counts are verified against the script's arrays and the case table rather than against a live container run.

## Anything you were unsure about

The page's `last-verified-against-source` marker is deliberately untouched. This change verified the scenario counts and the CI job description against the script, not the whole page, and the marker rules say to move it only when the page has been checked as a whole. The rewritten sentence names the refocus scenarios by their scenario file names (`refocus-parked-baton`, `refocus-active-baton`, `refocus-parked-baton-packet-off`) while the script's case keys drop the suffix; I chose the scenario names because the surrounding text names scenarios, and a reader following along from the case table can match them through the mapping in `result.mjs`. The seeded wording also changes slightly: the fixture and library pairs run healthy / lost-baton / healthy, while the hook case's pair is healthy / seeded / healthy, and the sentence says so rather than flattening the three modes into one shape.

## If this is security-related

Not security-related. The change is documentation only: it corrects the counts and the case names that two docs state about the CI scenario job, and moves no code, configuration or boundary.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
2026-10-24 12:16:59 -07:00
Mike Schwarz 9507d08734 fix(scope): mission create records metadata.name (#1936)
Found by the lab's onboarding self-scout. Thank you.

## Problem

`rig scope mission create` writes `mission.yaml` with `metadata: { status: planned }` and no `name`. The project lifecycle compiler requires `metadata.name` (`project-lifecycle-compiler.ts:76`), so a freshly scaffolded mission refuses to compile with `lifecycle_field_missing` until someone adds the name by hand.

## Fix

The scaffold writes `name: "<mission folder>"` beside `status: planned`.

- **Why the scaffold rather than a compiler default:** the compiler names the mission's workflow from this field (`lifecycle-<project>-<mission>`). An explicit name keeps that identity if the folder is renamed, and it matches hand-authored missions and the shipped worked example, which both write `name: <folder>`.
- **Why it's quoted:** a mission name only excludes whitespace and path separators. Written bare, YAML would read `#2` as a comment and `1.10` as the number 1.1.
- **Docs:** `docs/reference/sdlc-conventions.md` now says the scaffold writes the name, and that an older mission without one needs the line added.
- **An existing test updated:** the exact-shape scaffold test (`scope-convention-scaffold.test.ts`) now expects the name.

## Proof

- **RED** (`f25a6c2c`, test only, on main `dc9feb34`): the new case fails.
  - A probe compiling main's own scaffold output beside `docs/reference/project-release-profile.yaml` refuses with `lifecycle_field_missing`, the reported error.
- **GREEN** (`5ffd1e8e`):
  - `scope-commands` and `scope-convention-scaffold` pass 164 of 164, with the CLI built;
  - CLI `tsc` is clean;
  - `check-docs-guard` exits 0.

## Cost

Source +5/-2, tests +16/-1, docs +3/-1. No new setting, refusal or error code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 12:16:27 -07:00
Mike Schwarz 7624fd26e9 feat(provider): explicitly refresh usage for labelled Claude token credentials (#1901)
## What a user gets

Compare labelled Claude token credentials with `rig provider status --refresh --provider claude --claude-token-file /absolute/private/personal.token --credential-label Personal`. Each explicit refresh spends one model turn; status/TUI reads only cached evidence. Windows, resets, age, provider refusal and overage stay distinct. Labels are operator-attested; the older seat/account join stays unknown.

Reuse the refresh coalescer, provider views and reading history. Migration 134 preserves Codex rows and adds credential-labelled Claude readings, never token values. Probe flags use the [official CLI reference](https://code.claude.com/docs/en/cli-reference); real CLI/model execution was not run.

## How you verified it

222 provider tests and 7 migration checks pass; daemon compilation, CLI/TUI typechecks and docs guard pass. Tests use the sanitized event, migrated SQLite, real routes/CLI and fake executable children, including credential isolation, timeout, passive reads, restart and distinct credentials. Full repository/package suites and native provider qualification were not run locally.

## Cost and limits

+454/−40 lines (net +414); 180 test lines/254 added source lines = 0.71. No registry, poller or rotation policy. One credential per explicit refresh; no token-probe fan-out. A timeout does not prove no allowance was consumed.

New opted-in refusal: `invalid_claude_token_selection` (400); a token file must be a regular file of at most 8 KiB; group/other permission bits or a different owner produce a warning, not a refusal. Reader failures: `credential_file_unavailable`, `credential_or_probe_failed`, `probe_failed`, `probe_timeout`, `no_usage_event` (with existing `binary_not_found`). Missing percentages remain unknown; a rejected status is never converted into a percentage.

(Squash body corrected by the merge desk: this merged as migration 134, renumbered for the post-pin order, and round 1 made the token-file owner and mode checks warnings.)
2026-10-24 11:46:21 -07:00
Mike Schwarz dc9feb3453 fix(model-divergence): a Bedrock inference-profile pin matches its model (#1931)
Reported in #1926. Thanks to @george661 for the clear report.

## Problem

A Claude Code seat on Amazon Bedrock is pinned to a cross-Region inference-profile id, such as `global.anthropic.claude-opus-5-5`. Bedrock won't resolve the bare id for these models, so the pin has to keep that form. Claude Code reports the bare model id, `claude-opus-5-5`, and the model-divergence monitor compared the two exactly. So after a daemon restart every such seat was proclaimed divergent: "on a model nobody chose".

## Fix

`modelsMatch` (`model-divergence-monitor.ts`), for `claude-code` only:
- After the exact comparison fails, it drops a recognised Bedrock prefix from whichever side carries it, then compares again.
- The prefix is `anthropic.`, alone or after a cross-Region geography: `global`, `us`, `eu`, `apac`, `jp`, `au` or `us-gov`. Those are the geographies AWS lists for cross-Region inference profiles.
- The existing one-side `-YYYYMMDD` snapshot rule then applies as before, so `global.anthropic.claude-haiku-4-5` also matches `claude-haiku-4-5-20251001`.
- A different model after the prefix still diverges. Nothing matches by containment, and there's no second alias map.
- Codex, Pi and other runtimes keep the literal comparison.

## Proof

- **RED** (`8b12b44b`, tests only, on main `20f77d65`):
  - the 13 match cases fail, each proclaiming, including both pins from the report;
  - the 8 controls pass.
- **GREEN** (`d223faf2`):
  - `model-divergence-monitor`, `pi-effective-model-occupant` and `control-plane-census` pass 135 of 135;
  - the daemon typecheck is clean.
- **Controls that still diverge:**
  - another model after the prefix (`global.anthropic.claude-opus-5-5` against `claude-sonnet-5-5`);
  - a partial id (`…claude-opus-5`);
  - a prefix without `anthropic.` (`global.claude-opus-5-5`);
  - an unrecognised geography (`ca.anthropic.`);
  - two different snapshot dates;
  - `codex`, `pi` and no runtime.

## Not covered

- A pin that carries Bedrock's model version suffix, such as `…-v1:0`. What Claude Code reports for such a pin hasn't been measured here.
- An application inference-profile ARN, which doesn't name the model.

Both still proclaim, as before.

## Cost

Source +10/-2, tests +48. No new setting, refusal or error code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 11:13:42 -07:00
Mike Schwarz 8f13a2491e docs(context-packs): ship the ACP guide that rig acp run --help names (#1930)
## Problem

`rig acp run --help` ends with "Guide: rig context get reference/acp-agents.md". But the `reference` context pack ships only `getting-started`, `instance-layout`, `rig-spec` and `whats-new`, so an agent that follows the help gets a 404. The guide itself exists at `docs/reference/acp-agents.md`.

## Fix

- A symlink `packages/daemon/context-packs-src/reference/acp-agents.md` → `docs/reference/acp-agents.md`, linked the same way as the pack's other files, plus its manifest entry. The pack's purpose now says command help links to it too.
- The help-address test (`scripts/generate-context-packs.test.mjs`) expects `acp-agents.md` in the pack. It reads the `rig context get reference/…` address from `acp.ts`'s help and resolves it through the real address route, so the command help and the pack can't drift apart again.

## Proof

- **RED** (`a3bcb189`, test only, on main `6711432c`): the help-address test fails, and the address check alone shows `reference/acp-agents.md` returning **404** through the address route.
- **GREEN** (`b5863b64`):
  - the help-address tests pass 2 of 2;
  - all of `generate-context-packs.test.mjs` passes 20 of 20;
  - `generate-context-packs --check` validates 57 packs;
  - `check-docs-guard` exits 0.

## Cost

One symlink, manifest +7/-2, test +7/-2. No code change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 11:13:08 -07:00
Mike Schwarz 5a1b425c0d feat(stream): readers act, decline or mark a duplicate, and the poster sees it (slice 22 9b) (#1899)
## What

Slice 22, outcome 9, second increment: what a reader does about a stream item, and the poster seeing it.

- **`rig stream act <id>`** records exactly one action: `--work <qitem>`, `--decline "<reason>"` or `--duplicate <qitem|stream id>`. It runs as the transport identity; `--reader` applies only without one.
  - **`act`** links work the reader opened or joined through the queue. It doesn't create work, and the row must exist.
  - **`decline`** carries that reader's reason, and is theirs alone: it doesn't become a global no-action or touch anyone else's work.
  - **`duplicate`** links the queue row or stream item it repeats.
- **Actions are append-only** (`stream_reader_actions`, migration 133). A reader's newest row is its current action, so a correction stays inspectable. `GET /api/stream/:id/actions` returns the current actions and the history.
- **One accountable action, by context rather than refusal.** When other work is already linked to the item, by another reader or by the automatic path's accepted create or join, `act` still records the link. Its response names that work and the actor, and suggests joining it (a note on that row) instead of keeping a second one.
- **The poster's view (`rig stream show`)** lists each reader's current action, with the actor, the time, and the reason or link.
  - **When no automatic work is live** (nothing linked, or the automatic effect ended with intake), the newest reader-linked work decides the stage, followed through handoffs: `work_open`, `work_closed` or `work_unknown`.
  - **A second work row** is flagged as `parallelWork`.
  - **A daemon without reader actions** shows `readers: "unavailable"` rather than implying there are none.
- **Stream retention** keeps any item a reader acted on.
- **The expired-cursor 410's text** (from #1891) now says `--since <prune mark>` resumes after the pruned region, and suggests the caller's own last seen time or an earlier `--since` for all retained history, since referenced items can be older than the mark.
- **The kernel queue worker's guidance** names `rig stream act` beside `subscribe` and `read`.

## Why this shape

- **The reader links the work it opened.** Work creation stays with the queue verbs the reader already uses, and the action records the accountable link.
- **Telling, not refusing.** A second reader with different work is told about the first. A refusal would make OpenRig track the attempt's state, and the reader can judge whether to join.
- **Corrections stay inspectable:** append-only rows mean a changed mind is a newer row, not an overwrite.

## Cost

- **Net lines:** source +193/−4 (the action module 85, the outcome view 32, the CLI verb 23, routes 23 including the 410 wording, the migration 26, retention 2, registration 2); tests +154/−1; docs +1; kernel guidance +3/−2.
- **New error codes:** `action_invalid`, `work_required`, `reason_required` and `duplicate_required` (400); `stream_item_not_found`, `work_not_found` and `duplicate_target_not_found` (404). These are input checks on the new verb.
- **New refusals beyond input validation:** none.

## Tests

- **`packages/daemon/test/stream-reader-actions.test.ts` (5):**
  - act, decline and duplicate record the actor and the link or reason, and each malformed input gets its code;
  - two readers keep one accountable action: the second reader is shown the first's work and the automatic path's;
  - a decline is that reader's alone, and a correction keeps the earlier action in the history;
  - retention keeps an acted-on item;
  - routes act as the transport identity.
- **CLI `stream-outcome.test.ts`:** readers are shown; a decline alone changes nothing; reader-linked work decides the stage (open, closed with its resolution, through `with_intake`); `parallelWork` when automatic work differs; `readers: "unavailable"` on an older daemon.
- **CLI `stream.test.ts`:** `act` sends exactly one action, and refuses zero or two.
- **`stream-subscriptions.test.ts`:** the 410 message carries the new wording.
- **Mutation checks:** each broken rule made its targeted test fail, and the source was restored and compared:
  - dropping the other-work notice;
  - dropping the reader-decides stage.
- **Other checks:**
  - daemon `stream-subscriptions` 10, `stream-routes` 26, `stream-store` 11, `stream-dispositions` 12, `queue-retention` 14, `migration-fixture-parity` 5, `s02-p1-classification-persistence` 29, `startup-migrations-mirror` 2;
  - CLI `stream-consumer` 22, `stream-outcome` 11, `stream` 15;
  - daemon and CLI `tsc` clean;
  - `npm run test:repo`: 309 pass, 0 fail.

## Round 1

- **Retention keeps a stream item a duplicate action names,** for corrected (historical) actions too.
- **One handed-on chain isn't competing work.** The `act` notice and `parallelWork` compare work by its current row, handoffs followed.
  - A link whose current row is unknown keeps its own id.
  - The notice gives each named link's `current` row and, for automatic work, how its application ended (`applied`).
- **Same-millisecond actions** are ordered by their monotonic action id, which is record order.
- **Cost of this round:** source +48/−11, tests +32/−2.
- **REDs first:** review-r2's three fixture cases fail on `d7c737e2`'s source and pass after:
  - a duplicate-named item pruned;
  - a handed-on chain named as other work and flagged `parallelWork`;
  - a tie choosing the older closed work.
- **Checks:** daemon `stream-reader-actions` 7 and the stream set (75), CLI `stream-outcome` 11, `stream` 15, `stream-consumer` 22; daemon and CLI `tsc` clean; `test:repo` 309/0.

## Note

- **Migration 133:** #1897 takes 131 and #1895 takes 132. Whichever merges second may need a rebase of the migration list here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 11:08:10 -07:00
Dery Ferdika ebf96193ca test(scenarios): run the compaction-restore scenario through a launch script (#1924)
## What a user gets

Two of the scenarios in the library can now run as checks instead of sitting in the not-yet-runnable table. The scenario format declares five emit behaviors, and the runner bound one of them: `refocus` fired the real refocus hook, while `compaction`, `restore`, `slow_output` and `mid_turn_death` threw an unbound-action error by name. The `compaction-restore-resumes-role` scenario uses exactly the two behaviors whose shipped seams already exist, so it read as a scenario waiting on its runner. That runner gap is now closed for those two: `emit compaction` fires the packaged precompact hook the same way the stub runner's own script step does, so the seat gets its keyed restore-pending marker, and `emit restore` fires the restore bridge, which reads that marker and injects its one-shot directive, which the runner then mirrors to the seat's pane. `slow_output` and `mid_turn_death` stay unbound and still refuse by name, because their stub behaviors are pane mechanics rather than shipped seams, and binding them for real is a different piece of work.

## How you verified it

Tested on `e4d7f15a` with the change on top, on Windows with Node 24.21.0. The new `packages/daemon` `scenario-emit-behaviors.test.ts` passes 5 of 5. It covers the refusal of a behavior with no binding, the compaction seam receiving the seat's own identity (session name, the transcript path the stub runner derives from the seat cwd, the scenario daemon's `OPENRIG_HOME`, the seat's managed cwd) and reporting the marker it wrote, the restore seam receiving the same identity and its directive mirrored to the pane verbatim through the product's send path, a restore that delivers nothing failing loud instead of passing silently, and a seam error surfacing with its own message. The two behaviors are injectable on the runner options, which is what lets those tests assert the identity without running tmux. `packages/daemon` `scenario-normalizer.test.ts`, `stub-script.test.ts` and `run-scenarios-flag-refusal.test.ts` all pass (34 cases together), so the format, the stub script model and the CLI flag guard are unchanged. `npx tsc --noEmit` passes for the daemon project and `npm run lint` passes for all four packages. What I could not run: the `compaction-restore-resumes-role` scenario itself needs a scenario runner with tmux, and this machine has none, so the end-to-end run is the one proof still outstanding; the dispatch, the identity and the mirror text are covered by the unit tests. I also did not run the full `npm test` or the UI suite.

## Anything you were unsure about

The mirror of the restore directive goes through the product's send path, which the scenario file's own header describes as the runner mirroring the delivered directive to the pane verbatim. An alternative would have been a new control channel into the running stub, so the pane mirror comes from inside the seat the way the script steps do, but that is a change to the stub runner's contract rather than a binding, and the send path already delivers a terminal seat the exact text. If you would rather the mirror come from inside the seat, that is a follow-up I am happy to take. The `slow_output` and `mid_turn_death` behaviors are deliberately left unbound: firing them for real means pacing pane output and killing a seat mid-turn from the runner, which is a separate design decision. The compaction seam is given the stub seat's own transcript path rather than letting the hook discover one, because the restore bridge's premise gate delivers only when the event's transcript matches the one the marker recorded, and the stub's own authored transcript is the deterministic choice.

## If this is security-related

Not security-related. The change is inside the test harness: no route, credential, boundary or product behavior moves, and the seams it fires are the same shipped hook scripts the stub runner already executes for its own script steps.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
2026-10-24 10:59:11 -07:00
Mike Schwarz 20f77d6563 docs: the capability map teaches 0.7.1 (#1928)
## Why

The packaged capability map (`onboarding-width/public-what-you-can-do.md`) still teaches through `capability-delta-v0.7.0`. So a fresh agent on 0.7.1 can't discover what 0.7.1 delivered unless it already knows which `--help` to ask. This refreshes the map on main, so the next package carries it. It's docs only.

## What changed (one file, +130/-24)

Everything comes from the 0.7.1 cut: its CLI and daemon source, and its packaged `reference/whats-new.md`.

- **The header** names `capability-delta-v0.7.1` and ninety top-level verbs (88 in 0.7.0, plus `acp` and `edge`). It points to `rig context get reference/whats-new.md` for what changed in the installed version.
- **Finding out what's true:**
  - `rig ps --nodes`'s `ATTN` column, and reading it through `--json`/`--fields` rather than by column position.
  - `rig seat status` showing a seat's planned next launch and its terminal policy.
  - The `needs-input` pickup state.
- **Reaching another agent:**
  - `rig send --urgent`.
  - The override's stop-and-show on an input box that already holds text, plus `--type-without-checking`, with the combinations not yet checked on a live seat.
  - The wider `target_needs_input` rule.
  - `rig stream subscribe`, `read`, `subscriptions` and `unsubscribe`, `rig stream show`, and stream retention.
  - `rig acp run`.
  - Herdr tiles reattaching after a Herdr restart.
- **Work, wakes and teams:**
  - `rig queue update --evidence-ref` on a close.
  - `rig watchdog update` and the token/percent context thresholds.
  - `rig up --name`, `rig edge add|remove`, and per-member Claude setup (`agent_file`, `append_system_prompt_file`, `plugin_dirs`, `advisor`).
  - The community-harness conversation fields (`launch_result`, `resume`, `names_conversation`).
- **Recovery and settings:**
  - `daemon.db_conflict` and `--allow-db-conflict`.
  - `policies.claude_compaction.preparation_root`, and managed compaction resuming after a daemon restart.
  - The typing-guard levels.
  - `rig seat set-terminal-policy`.
  - `provider.selected` and `rig setup --router-line`.
  - Remote `--host` on `rig auth` and `rig seat restart`.
  - The long-lived-token usage limit.
- **Tracking work:** `rig project wake --decide` and `rig project decisions status`, marked opt-in and off by default.
- **People:** every ask lands on the Feed, the person's `slack-subscription` decides what reaches Slack, and `--alert` is for what only they can do.
- **MCP:** the delegation-loop tools, `rig mcp serve --http` and `rig mcp token add`.

Opt-in and off-by-default features, and the known limits, are marked where they apply. Nothing merged after the 0.7.1 pin is described.

## Checks

- `node scripts/check-docs-guard.mjs` passes.
- There's no other copy of the map in the repo to keep in step.
- `generate-context-packs --check` needs a built daemon, so CI's repo checks cover it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 10:22:42 -07:00
Mike Schwarz 6711432c49 fix(substance): allow the startup guide's exact preparation-root sentence (#1929)
## What

The release substance gate (`npm run gate:substance`) refuses `docs/reference/agent-startup-guide.md` because one accurate
sentence names the default compaction preparation root, `<cwd>/.openrig/compaction/preparation/`, and that path prefix is
on the deny list.

This adds that one sentence to `allowed_context_lines` in `scripts/internal-tokens.generated.json`, the existing exact-line
allowance (the file is the policy source despite its name; `gen-control-plane-json.mjs` copies it and never rewrites it).
The whole line must match, so appended text or a concrete path in place of `<cwd>` is still refused.

Unchanged: the deny tokens, the path prefixes, the guide, the gate and every runtime source.

## Tests

`scripts/internal-leak-scanner.test.mjs` gains one test against the product rules:
- the shipped guide scans clean, at its repo path and its packaged path;
- the sentence with appended text, or with a concrete directory in place of `<cwd>`, is still refused for `.openrig/compaction/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 10:11:50 -07:00
Mike Schwarz c0da35be27 feat(seat): move a live seat between Codex and Claude Code, successor first (#1895)
## What this gives a person

When a provider reaches its usage limit, or a seat would do better on the other runtime, a person or their operator agent can move that one live seat between Codex and Claude Code without rebuilding the team:

```bash
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"
```

The seat keeps its address, connections, open queue rows and in-progress claims, and its successor gets a handover of what it was doing. The new runtime takes over before the old one stops. If anything goes wrong before the cutover, the current occupant keeps the seat, and `rig seat status` says so.

This builds on the design in #1214 by @Aschenbath (a staging session with a pane swap, a durable switch record, one commit transaction, recovery that rolls back before commit and finishes forward after, and re-resolving the seat's materials for the target), rebuilt on today's main with the changes below.

## How it works

1. **Preview (`--dry-run`).** It lists 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 default), what is cleared, startup slash commands a switch doesn't run, and the costs. A seat that can't switch (another runtime, more than one pane, no current occupant, a target that isn't installed, materials that don't resolve, a switch already open) is reported here, before anything stops.
2. **Successor first, under the seat's lifecycle lease.** The switch is journaled (`seat_runtime_switches`), then the successor starts in a staging session with the seat's name, a reserved occupant generation and the target's own materials (its seat skill storage, projections and startup files, with the identity's `runtime:` line rewritten).
3. **Handover and confirmation.** The successor receives the predecessor's screen, the seat recap and its open rows, and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "<what I'll do first>"`. The daemon accepts that only from the successor's own session and reserved generation. This is the evidence that the successor can carry on: its model read the handover, its tools run, and it reaches OpenRig as the seat. With no confirmation within 180 s (`--confirm-timeout`), nothing commits, the staged successor is retired, and the predecessor keeps the seat.
4. **Cutover.** The panes are swapped, then one transaction commits the seat's runtime, cleared settings, permission selection, startup context, session, binding and tenure. It moves in-progress claims from the predecessor's generation to the successor's, takes a snapshot for the switch (restore tier, so the newest default restore matches the new runtime), and records a `seat.runtime_changed` event. The predecessor is then retired and the successor is told it now holds the seat.
5. **Recovery.** At daemon start, and with `rig seat set-runtime <seat> --recover`, an interrupted switch is finished from its record: before commit it is rolled back (panes swapped back, staged successor retired); after commit it is finished forward and never handed back. Where the panes match neither side, nothing is touched and status says why.

**Who holds the seat** is decided by one fact, the switch's `committed_at`, which is set in the same transaction as the configuration. Its `phase` records physical progress, and `attention` records why recovery stopped.

## The checks stay narrow

During a switch, the predecessor and successor share the seat's session name. To tell them apart, the CLI now sends the caller's own occupant generation (`X-OpenRig-Occupant-Generation`, from the seat's `OPENRIG_OCCUPANT_GENERATION`). The daemon refuses only the two generations a switch record names, on the seat it names, on queue create, claim, unclaim, update, handoff and handoff-and-complete, send and broadcast, and every mutating seat route (`set-model`, `set-effort`, `set-permissions`, `set-cwd`, `handover`, `stop`, and the rest):
- `occupant_retired_by_switch`: the predecessor after commit;
- `switch_not_committed`: the staged successor before commit, except its own `--confirm` and `--recover`. A successor whose switch was abandoned stays fenced while its retirement is unconfirmed (the switch keeps attention), and recovery at the next daemon start retires it; a failed retirement isn't a release of custody.

No header, an unknown generation, re-adopted seats and every other seat behave exactly as before. The activity hook ignores a retired predecessor and a staged successor, and keeps the staged successor's native identity on the switch record rather than the seat.

## Refusals and error codes

The operation itself: `invalid_runtime_settings`, `runtime_unchanged` (it names `set-model`, `set-effort` and `set-permissions`), `runtime_switch_unsupported`, `runtime_target_unavailable`, `runtime_switch_pending`, `runtime_switch_failed` (`committed: false`), and `runtime_switch_confirmation_refused` on the confirm route. The CLI adds `runtime_switch_outcome_unknown` when it stops waiting (it points to `rig seat status`). On existing paths, there are two: `occupant_retired_by_switch` and `switch_not_committed`. The existing lease refusal `draft_aware_lifecycle` is unchanged.

A `rig send` to a seat that is switching isn't refused or kept waiting. The daemon holds it at once on the existing held path (`outcome: retained`, `reason: runtime_switch_in_progress`) and says the seat is switching and until when; a send that had already been waiting when the seat moved says nothing was typed and to send again. Both are on the send's own answer, so the CLI makes no extra request.

## Costs (also in the preview and `docs/reference/getting-started.md`)

- A new conversation: native history isn't converted; only the handover carries over.
- The same address, a different pane and scrollback.
- The team file isn't rewritten. The database and `rig export` show the new runtime, while an imported YAML keeps the old one, so a later converge reports a deferred `change_runtime`.
- Source settings are cleared, not mapped: model, effort, Codex profile and an explicit native permission choice. A model or effort the agent spec declares for the source runtime doesn't apply either. Member and rig permission policy are kept.
- Two processes until cutover.
- Messages sent to the seat during the switch are held, not typed, and need sending again afterwards.
- A failed switch can leave the target's prepared files.
- Codex and Claude Code, single-pane seats only.

## Known limits

- Claude launch additions such as an agent file aren't applied to a switched seat.
- Startup slash commands aren't run; the preview lists them.
- On a seat with the draft-aware typing guard, a person typing into the old pane during the switch stops it (the guard protects their draft), and the predecessor keeps the seat.
- A successor's context-usage sidecar may be cleared at commit by the name-keyed invalidator; it is rewritten on the next reading.

## Tests

All with fakes (no real agent seats). Each test states what the person or calling agent sees.
- **New:**
  - `seat-runtime-switch-service`: preview, both directions, confirmation, no confirmation leaving the predecessor, exact claims, recovery at each point, and the fence on a switch's two processes. An abandoned successor that couldn't be retired stays fenced until recovery retires it, and a retired predecessor is refused on `set-model` and 11 other mutating seat routes while reads and other callers are unchanged. These cases failed first against the code without the fence.
  - `runtime-switch-custody`: the two refusals and the unchanged cases. A send to a switching seat is held at once without being typed; a send after commit goes as usual; a send that waited while the seat moved says so; a seat with no switch is unchanged.
  - `runtime-switch-materials`: settings from the seat's agent spec per runtime, cleared settings, target storage and startup files.
  - `seat-runtime-switch-route`.
  - CLI `seat-set-runtime` and `send-runtime-switch` (one request, the daemon's answer shown), plus a block in `client-headers`.
- **Daemon:** 38 files, the new ones plus the neighbouring suites (handover, launcher, queue, transport, activity, snapshots, restore, startup, tmux, migrations, stream subscriptions): 1,385 passed, 5 todo.
- **CLI:** `seat-set-runtime`, `send-runtime-switch`, `client-headers`, `local-owner-routing`, `send`, `send-header`, `seat` and `seat-lifecycle-verbs`: 8 files, 256 passed.
- **Build and docs:** the daemon build and CLI `tsc --noEmit` are clean, the first-run reference check reports 0 failures, and the docs guard passes.

Not run here: switches between real Claude Code and Codex seats in both directions, a real confirmation and a successor stuck at an approval prompt, a seat switching itself, real interruptions with a daemon restart, and a real restore after a switch. These need real accounts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 10:01:10 -07:00
Mike Schwarz c20c83d2bc fix(model-divergence): a Claude pin matches its dated snapshot (#1927)
## Problem

A Claude seat pinned to an undated model id, such as `claude-haiku-4-5`, answers with that model's dated snapshot id, `claude-haiku-4-5-20251001`. The model-divergence monitor compared the two strings exactly, so it proclaimed a divergence for a seat running exactly the model it was pinned to. Every Haiku seat pinned this way gets the false alert, and false alerts teach people to ignore the real ones.

## Fix

`modelsMatch` (`model-divergence-monitor.ts`), for `claude-code` only: after the existing exact comparison (pin through `CANONICAL_MODEL_PINS`, `[1m]` dropped), when exactly one side ends in a `-YYYYMMDD` snapshot date, the comparison drops that date.

- `claude-haiku-4-5` matches `claude-haiku-4-5-20251001`, and a pinned snapshot matches its undated id.
- Two different snapshot dates still diverge: someone who pins a dated snapshot means that one.
- `CANONICAL_MODEL_PINS` stays the single alias home, and nothing matches by containment. An unmapped alias such as `haiku` still diverges, as before.
- Codex, Pi and other runtimes keep the literal comparison. The Pi path's runtime-less call is unchanged.

## Proof

- **RED** (`ac8bf31f`, tests only, on main `a65262da`): the five match cases fail, because each proclaims; the eight divergence controls pass.
- **GREEN** (`a8ab416e`): `model-divergence-monitor`, `pi-effective-model-occupant` and `control-plane-census` pass 114 of 114, and the daemon typecheck is clean.
- **Controls that still diverge:** another family with a date (`claude-haiku-4-5` against `claude-sonnet-4-5-20250929`), two different dates, a prefix pin (`claude-haiku-4`), a trailing extra after the date, an unmapped alias (`haiku`), and `codex`, `pi` and no runtime.

## Cost

Source +9/-1, tests +40. No new setting, refusal or error code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 09:23:08 -07:00
Mike Schwarz f229ddf818 fix: escalate a seat held at a choice prompt no hook announces, one person alert at a time (#1919)
## What a person gets

A seat stuck at a choice prompt while it holds open work now reaches someone. Before, a Claude seat at its usage-limit dialog could sit for many hours: every wake was correctly refused as `selection_prompt`, and nothing escalated. Addresses #81.

## Why it stayed silent

The prompt-refusal pass escalated only when the seat's own evidence read "blocked". For Claude, that evidence comes from a permission request a hook announced, or a hook that means "waiting on a person". A choice dialog such as the usage-limit one announces neither, so the read stayed "unknown" and the pass skipped the seat on every tick.

## The change

- **The refusal counts as evidence.** The transport's refusal names the prompt it read. While a seat's latest refusal is at a choice prompt (`selection_prompt`), the oldest open choice refusal that nothing newer has cleared reads "blocked" once it has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 s by default). A prompt answered within seconds never escalates, a cleared choice prompt lends no age to a later refusal, and a choice prompt that stays up keeps its age when new work's refusal arrives. An episode closes when the seat resumes, and a clear or move-on hook retires the alert as before.
- **Choice-prompt alerts follow `queue.wake_human_rung`.** An alert whose every refusal was at a choice prompt is tagged `prompt-kind:choice`. Under `explicit-only` it stays with the operator seat as a visible row and posts to no person, unless that seat is a person. A permission prompt, which only a person can approve, keeps posting as before, including one that later joins an alert the setting kept from a person; delivery already accepted is never repeated. The default (`always`) is unchanged.
- **One choice-prompt alert to a person at a time on a machine.** While one choice-prompt alert has gone to the delivery engine and stays open, each other choice alert names it ("operator waiting on …") and waits, then posts after that one retires. A prompt many seats hit together, such as a shared usage limit, posts once instead of once per seat. Permission-prompt alerts never wait behind it. The waiting alerts stay visible rows.
- **Unchanged:** nothing is typed into the blocked seat; other refusal reasons with no evidence wait for evidence as before; the orchestrator rung still comes first.

## Who it reaches

- **The orchestrator rung:** the seat's orchestrator (the source of its `delegates_to` edge) is woken on one aggregate row. If that wake lands, the ladder stops there.
- **The operator rung,** when the seat has no orchestrator or its wake fails: the aggregate row goes to `workspace.operator_seat_name`, and the delivery engine posts an ALERT to the first entry in the human registry. With no registered human, the rung stays unresolved and the row is the only signal.
- A permission-prompt alert reaches that person whatever `queue.wake_human_rung` says. A choice-prompt alert does so under `always` (the default); under `explicit-only` it reaches a person only when the operator seat itself is one.

## Tests

- **End to end** (`held-wake-escalation.test.ts`): a Claude seat whose pane shows a choice dialog, with no hook. The real transport refuses the wake and records `(selection_prompt)`, the production prompt reader reads unknown, and after one retry interval the seat's orchestrator is woken. The seat gets no input.
- **The pass** (`prompt-blocked-alert.test.ts`):
  - a choice-prompt refusal with unknown evidence: no alert inside the interval, then one post to the registry's first human after it, and never a second;
  - two seats blocked together: one post, the other alert names it and waits, then posts after the first seat's prompt clears;
  - a permission-prompt seat beside a choice-prompt seat: both post, and neither waits;
  - under `explicit-only` with an agent operator seat: the choice alert stays with that seat, tagged, and posts nothing, while a permission alert beside it posts; a reminder refused twice at a choice prompt follows the setting too;
  - a choice prompt cleared before a new choice or permission refusal lends it no age (both quiet), and one that never cleared keeps its age when new work's refusal arrives;
  - under `explicit-only`, a permission refusal that joins a choice alert kept from a person posts once, as a new episode or as a reminder whose latest refusal changes kind;
  - guards: another refusal reason with unknown evidence still waits; a choice prompt with newer clearing evidence stays quiet.
- **RED on main** (the first cases only): the end-to-end case wakes nobody, the single seat gets no post, and two seats get two posts. The guards pass there too.
- **RED on the first version of this change** (the later cases): the permission alert is held behind the choice alert; a cleared choice prompt's age raises an alert; under `explicit-only` the choice alert still posts; the reminder alert carries no choice tag. The continuity case passes there too.
- **RED on the second version** (the last two cases): the joined permission alert posts nothing.
- **GREEN here:** every CI job; `prompt-blocked-alert.test.ts` 80 tests.

## Limits

- With the default setting, a usage-limit dialog reaches the same person as any other prompt alert, at most one choice alert at a time per machine. Sending it to an ops seat instead takes `queue.wake_human_rung: explicit-only` and an operator seat that isn't a person; this change doesn't set either.
- Every choice prompt follows the setting, not only usage limits: under `explicit-only`, a choice only a person should answer (a resume picker, say) also stays with the operator seat, whose agent escalates it through the usual paths.
- While the first posted alert's seat stays blocked, later alerts on that machine wait unposted; they're visible as rows.
- Only `selection_prompt` refusals use the new rule. A watchdog reminder refused twice at a prompt keeps its existing path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 09:14:17 -07:00
Devulapalli Naga Sri Vaishnavi 5607790817 fix(daemon): drop queued terminal input after teardown (#1636)
## What a user gets

When a terminal client disconnects while an earlier input write is still in flight, later input that was already queued is no longer sent to the seat after the broker tears down. The write already in progress is allowed to finish; only queued writes that have not started are dropped.

## How you verified it

- On base `ccdce88e`, the deterministic regression test fails: the broker records and sends both `first` and queued `second` after teardown.
- On HEAD `091f28a3`, `npm run test -w packages/daemon -- test/terminal-session-broker.test.ts test/terminal-ws-route.test.ts` — 59 passed.
- On HEAD `091f28a3`, `npm run lint` — passed (daemon build plus TypeScript checks for daemon, UI, CLI, and TUI).
- `npm test` and the root `npm run build` were not run; the focused daemon tests and repository lint/typecheck cover the changed module and its route neighbor.

## Anything you were unsure about

The guard is inside the serialized input callback so it covers writes queued before teardown without changing the ordering or delivery of inputs while the broker is live.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
2026-10-24 09:03:39 -07:00
Devulapalli Naga Sri Vaishnavi 7e04dd3ab1 fix(daemon): resolve queue work by catalog project (#1526)
## What a user gets

Refs #1520.

`rig queue whoami --json` now resolves a project-tagged in-progress row against its catalogued project's missions root only when the row has exactly one distinct `project:` tag and that catalog entry is healthy. `--candidates` uses the same per-item resolution while keeping its host mission inventory and default `missionsRoot` on the configured `workspace.slices_root`.

Rows without a project tag retain the existing configured-root behavior. Missing, invalid, unreadable or duplicate catalogs, unknown or broken project entries, and multiple distinct project tags fall back to the configured root and explain the real reason in the basis instead of guessing or claiming that no root is configured.

## How you verified it

- Base-fail probe on the pre-fix `origin/main`: with catalogued `alpha` and `beta` projects sharing `release/slices/01-work`, `workspace.slices_root` pointed at beta, and an in-progress `project:alpha` baton, `whoami` returned beta's work-node path.
- Fix-pass: the same focused regression returns alpha's path for both `currentWork` and the opt-in `workCandidates` projection.
- `npm run build -w packages/daemon` — passed.
- `npm run lint` — passed.
- `npm run test -w packages/daemon -- test/current-work.test.ts` — 14 passed.
- `npm run test -w packages/daemon -- test/queue-routes.test.ts` — 209 passed, including route-level fallback coverage for missing, invalid, duplicate, unknown, broken and multiple-tag cases, plus candidate metadata.
- `test/refocus-work-packet.test.ts` was attempted separately; its 15 failures are in the native-Windows trace/resolver fixture path (CRLF/Unicode rendering and unavailable resolver command), not in the changed current-work unit or queue-route suite.

## Anything you were unsure about

- The route reads the existing project catalogue only when available. If the catalogue cannot be read, it preserves the configured-root behavior and records that fallback.
- The maintainer's separate decision about whether a selected project's root may fall back to the configured root for a missing mission/slice remains intentionally unchanged.

## If this is security-related

Not security-related. The bug affects queue context/refocus correctness inside a trusted OpenRig workspace.

- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
2026-10-24 09:02:44 -07:00
Mike Schwarz e784cef176 fix(gateway): a Slack reply keeps its sender; Slack rows name who wrote (#1893)
## What this does

Two changes for a Slack workspace with more than one registered person, so a reply that comes through the gateway keeps its sender's identity.

1. **A Slack reply from someone else doesn't resume another person's question.**
   - When a different registered person replies in Slack to a question parked on someone else, their reply still reaches the agent as their own message.
   - The question stays parked on its person, and nothing is written to it.
   - Unchanged: a typed reply to a direct ask was already checked against its recipient, and a different person's button click is still ignored.
2. **Slack rows name who actually wrote.** A Slack message, answer or reaction lands on its row with a summary naming the registered person who wrote it (`<address> via Slack: …`), not one fixed title. Tags are unchanged.

## What doesn't change
- An action at the terminal (the TUI, the Feed, Mission Control's verbs) is the primary user's. It keeps its existing authority and is recorded as it is today.
- Agents are unaffected.

## Tests
- **New:** `slack-reply-from-another-person.test.ts`. A different person's Slack reply leaves the parked question untouched, while a terminal resolve of the same question works as before.
- **Updated:** the Slack inbound summary tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 09:01:30 -07:00
Mike Schwarz 3064e679a7 fix(daemon): clear-attention can clear a prompt the latest hook still names (#1912)
## What and why

**The harm:** after an operator declined a Claude seat's permission prompt ("No"), the seat stayed unreachable. Every `rig send` was
refused with `(PermissionPromptConfirmed; latest hook, pane unrecognized)`, and `rig seat clear-attention` answered `not_in_attention`.

**How it happens:**
- **Where the state comes from:** `PermissionPromptConfirmed` is OpenRig's own record. The activity poller writes it when it sees a permission prompt on screen
  (`startup.ts`, `permissionPromptChanged`), and it writes `PermissionPromptCleared` only once its screen read says definitely no prompt.
  After the decline, the screen read as unrecognized, so nothing ever recorded the prompt cleared.
- **The send gate:** past the 15-second send window it reads the pane. When the pane is unrecognized, it keeps a latest hook that says the
  seat waits on a person (`classifySendReadiness`). That rule is deliberate: it stops a send from pressing Enter on a picker the pane can't
  show. So the decline left a refusal that nothing could clear.
- **`clear-attention`:** it knew only the startup, restore and pane-identity classes.

**What changes:**
- **A new class for `clear-attention`:** a latest hook that still says the seat waits on a person, or a Codex pending permission.
  - Without `--reason`, it says what to do instead of `not_in_attention`.
  - With `--reason "<what you saw>"`, it records `PermissionPromptCleared`, the event the poller writes when it sees a prompt go, and an
    `attention_cleared` event carrying the reason.
  - Sends and wakes then go through. The send gate still reads the pane on every send, so a prompt it recognizes still refuses.
- **The refusal names the way out:** when a refusal rests on a retained hook alone (a Claude waiting hook, or a Codex permission request the
  pane can't confirm), it adds "Look first: `rig capture <seat>`. If that prompt is gone (declined or answered), clear it:
  `rig seat clear-attention <seat> --reason "<what you saw>"`." The CLI help mentions the class.

**Why not automatic:** recognising the post-decline screen would let the poller clear on its own, but no capture of that screen exists.
Treating every unrecognized pane as clear would drop the picker protection. The operator who declined the prompt can say it's gone.

**The activity service too (round 1):** the clear also settles the activity service's pending prompt
(`SeatActivityService.settlePermissionPrompt`, wired in `startup.ts`). So the wake ladder, which asks that oracle first, and `rig ps` agree with
the clear instead of reading the seat blocked. A new permission hook starts a new request.

U3 (#1892) is merged, and this builds on it.

## Cost (net vs main, after round 1)

| | Lines |
|---|---|
| Source: `seat-attention-reconciler.ts` | +38/-5 |
| Source: `session-transport.ts` | +16/-4 |
| Source: `seat-activity-service.ts` | +16 |
| Source: `startup.ts` | +1 |
| Source: CLI help in `seat.ts` | +6/-2 |
| Tests | +107/-2 |

- **New refusals:** none of anything that works today. The new class only adds a way to clear; without `--reason` it explains instead of
  clearing.
- **New codes or settings:** none.

## Proof

**Round 1** (`440482bd`):
- **RED** (round 0's four source files, the round's tests): 6 fail. The activity service still counts the prompt after the clear (1, not 0).
  The four Codex hook-only refusals lack the hint, and the Claude hint lacks "look first".
- **GREEN:** 15 files, 744 tests, adding the Claude and Codex permission-pending and held-wake escalation files. The new case runs through
  a real `SeatActivityService` and `makePromptStateReader`: blocked before the clear, clear after, and no re-confirmation on the next poll.
- **Typecheck:** daemon `tsc` is clean.

**Round 0:**

- **RED** (main's two source files, the new tests): 3 fail.
  - `clear-attention` answers `not_in_attention` with and without a reason.
  - The refusal carries no hint.
- **GREEN, the touched files:** 133 tests across `seat-attention-reconciler` and `send-prompt-guard`.
- **GREEN, adjacent files:** attention, needs-input provenance, claim-hint prompt guard, parked-owner consumer, prompt answer delivery,
  prompt-blocked alert, prompt-clear continuation, wake ladder, sessions routes, session-transport. 694 in total.
- **Typecheck:** daemon `tsc` is clean.
- **The CLI change** is help text only; no CLI test pins it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 08:59:37 -07:00
Mike Schwarz a65262dafb fix(cli): give the 14 undescribed subcommands a help summary (#1923)
`rig <verb> --help` lists each subcommand with its description. These 14 had none, so their parent's help showed a blank summary:

- `rig chatroom send`, `history`, `watch`, `topic`, `wait`, `clear`
- `rig project experimental enable`, `disable`, `status`
- `rig context profile`
- `rig health diagnosis record`
- `rig env status`, `logs`, `down`

Each new summary is taken from what that command's action does.

`lazy-program.test.ts` gains one case. It walks `createProgram()` and requires every command to have a description, so a new command can't land with a blank summary unnoticed. The same traversal finds the 14 blanks at `7923e13e` and none on this branch.

Text only, apart from that test. No behaviour, option or output changes. `docs/as-built/cli-reference.md` doesn't list descriptions, so it is unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 08:29:24 -07:00
NIKHILA 4dbb968d46 fix(daemon): recognize non-gpt Codex model footers (#1778)
## What a user gets

Codex resume detection recognizes a resumed Codex TUI whose status footer uses a versioned non-`gpt-*` model identifier, including the warning layout reported in the issue.

The process-corroboration proof now reaches every relevant path:
- Runtime adapter readiness and resume checks.
- Strict full-restore reconciliation of an already-flagged agent.
- Startup route pane observation.

The strict reconciler reuses its existing exact Codex resume-token process proof instead of performing a second identity check. The startup route uses the shared process census for the same verify-and-reprobe behavior.

Non-`gpt-*` footer evidence is accepted only when its status row appears below the last composer line. This prevents a stale shell capture such as `project2 · main` followed by `› codex resume ...` from being promoted when Codex has only just become the foreground process.

Trust, update, hook, and model-selection gates remain intact.

## How you verified it

- Probe and startup route tests — 176 passed.
- Focused runtime stale-shell and warning-footer tests — 2 passed.
- Focused strict restore reconciliation test — 1 passed.
- `npm run build` — passed.
- `npm run lint` — passed.
- `git diff --check` — passed.

The full daemon/repository suites were attempted on Windows but include unrelated existing environment-dependent failures, including Unix shell-path assumptions, temporary repository path handling, missing native `better-sqlite3` build prerequisites, and fixture/package guard failures.

## Anything you were unsure about

Upstream GitHub Actions still needs its normal maintainer approval for this fork's workflow run; local verification is green for the changed paths.
2026-10-24 08:15:40 -07:00
Mike Schwarz e4d7f15a28 docs(cli-reference): regenerate the registered command tree at 7923e13e (#1922)
`docs/as-built/cli-reference.md` said it was verified against `92a2b8f7`. This regenerates its command tables and counts from the Commander tree returned by `createProgram()` at `7923e13e`, the file's own method.

**Counts:** 90 top-level registrations, 399 registered command objects and 1,255 option objects (previously 87, 356 and 1,084).

**What changed in the tables:**
- New rows: `rig seat set-terminal-policy`, `rig seat set-effort`, `rig seat effort`, `rig seat restart`.
- New options: `rig daemon start --allow-db-conflict`, `rig send --type-without-checking`, `rig queue transitions --full`, and `rig seat launch --plan`. `rig seat launch --reason` is no longer required.
- `rig gateway human show` and `set` now appear in registration order.

The hand-written notes under the tables and inside option cells are kept. Each was checked against the current source.

**How it was checked:** every table row was parsed back and compared with the walked tree, and all 399 rows and their options match. The same walk at `92a2b8f7` reproduces that version's 87 top-level registrations and 356 command objects.

Docs only. No source, test or other doc changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 08:00:57 -07:00
Mike Schwarz f0ee66c34c fix(queue): a cross-host write that times out reads as unknown; the CLI waits for the daemon's answer (#1911)
## What this changes

A cross-host `rig queue create`, `handoff` or `handoff-and-complete` goes to the selected daemon, which forwards it to the remote daemon with a 10 s deadline (`QUEUE_FORWARD_TIMEOUT_MS`). When the remote is slow, two things made the write read as failed even though the row could still land.

1. **The daemon's forward timeout is now an unknown outcome** (`routes/queue.ts`).
   - **Before:** it answered 502 `remote_queue_write_failed` with `failureClass: "unreachable"` and no outcome. But the write had been sent, so the remote may still commit it.
   - **Now:** it also carries `outcome: "indeterminate"`, and the detail says "the remote may still commit it". `failureClass` is unchanged.
   - **What the CLI does with it:** it already renders that outcome as "The write outcome is INDETERMINATE … reconcile by ID before any retry", with the request ID and recovery steps.
2. **The CLI waits for the daemon's answer** (`commands/queue.ts`).
   - **Before:** it waited only its 5 s client default, so a remote that answered in 5–10 s became a local timeout, though the daemon would have returned the remote's answer.
   - **Now:** cross-host queue writes wait 15 s, longer than the forward deadline. Writes to the local daemon keep the default. Cross-host `rig send` already waits longer for the same reason.
3. **The CLI's timeout headline** now reads "queue request got no answer in time (routing to host '…')" instead of "queue transport failure". The INDETERMINATE line and the recovery ID still follow.

## Why

A caller told "failed" (or "unreachable") retries, and a retry without the original ID creates a second row once the first one lands. A timed-out cross-host write should read as unknown, with the ID to reconcile by, which is what the CLI already does for its own lost connections.

## Tests
- **Fail-first:** with these tests on the unchanged source, the new daemon case and four CLI cases fail. The local-default cases pass before and after.
- **New daemon case** (`queue-cross-host-create.test.ts`): the origin hasn't answered when the 10 s deadline passes (fake timers). It returns 502 `unreachable` with `outcome: "indeterminate"`, and nothing is written locally.
- **New CLI file** (`queue-cross-host-write-wait.test.ts`):
  - cross-host create, handoff and handoff-and-complete pass `timeoutMs: 15000`, and local ones pass no override;
  - a CLI timeout reads "no answer in time" and INDETERMINATE, with the `--id` to reconcile by.
- **Runs:**
  - daemon `queue-cross-host-create` and `queue-cross-host-handoff`: 70 passed;
  - CLI `queue-cross-host-write-wait`, `queue-host-refusal-hint`, `d14-loud-queue-transport-failure` and `queue-write-receipt`: 120 passed;
  - the daemon build and CLI `tsc --noEmit` are clean.
- **Not run:** `queue-host-qualifier-route` (it spawns the built CLI), the full suite, and a real slow remote.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 07:52:31 -07:00
Mike Schwarz e2c05b1ade fix(daemon): a launch whose tmux made no session isn't reported created (#1910)
## What and why

`rig up` could report a seat **launched** (status `completed`, exit 0) when tmux had made no session at all. The only signs were follow-on
warnings such as `error connecting to <socket> (No such file or directory)`.

- **The cause:** tmux can exit 0 having made nothing. When the server can't create its socket, it prints
  `error creating <socket> (Permission denied)` and quits, and the client still exits 0. That happens, for example, when the socket's parent
  directory exists but this user can't write to it. `createSession` treated exit 0 as created.
- **The change:** `new-session -P -F '#{pane_id}'` prints the pane it made. When it prints none, `createSession` reads the session back
  with `has-session`, and only a found session keeps the launch.
  - **A positive "can't find session"** fails the launch as `session_not_created`.
  - **A read-back that can't reach or read tmux** (no server, a socket error, an unexpected probe failure) fails it as
    `session_unconfirmed`: "tmux returned exit status 0 without a usable pane id; creation of session … could not be confirmed (…)". The
    message carries tmux's own read-back error and says what to check: the `TMUX_TMPDIR` directory, or the directory of an explicit `-S`
    socket path, must be writable by this user. `probeSession` itself documents a no-server answer as "not determined", so it isn't
    reported as absence.
- **What's preserved:** normal pane output takes the unchanged path, and a launch that prints no pane but whose session is found still goes
  ahead.

## Measured (tmux 3.6a; private sockets, sentinel names)

| Command, with the socket's parent present and mode 0500 | Exit | Output |
|---|---|---|
| direct `new-session -P -F '#{pane_id}'` | 0 | `error creating … (Permission denied)` on stderr, no pane id |
| `has-session` | 1 | `error connecting to … (No such file or directory)` |
| the same `new-session` with an unwritable `TMUX_TMPDIR` | 1 | (the client can't create the directory, so main already reports failed) |

Another platform showed the same with tmux 3.2a.

## Notes

- **The shared classifier is unchanged.** `probeSession`'s transport classifier doesn't recognise an explicit `-S` path's error (the message
  starts with `Command failed:` and names no `tmux-<uid>` path). So the read-back uses tmux's own stderr directly rather than widening a
  classifier every caller shares.
- **On top of #1907:** main (`e3ee9e0c`, #1907) is merged in. The read-back follows #1907's `runEnvironmentCommand`, so it covers its
  `start-server ; source-file` chain too, which exits 0 the same way.

## Cost (net vs main `e3ee9e0c`)

| | Lines |
|---|---|
| Source | +28 in `tmux.ts` |
| Tests | a new file, `tmux-create-readback.test.ts`, +78 |
| Adapted stand-ins | 6 one-line changes, so `new-session` prints a pane id as real tmux does; their pinned command lists are unchanged |

- **New codes:** `session_not_created` and `session_unconfirmed`, only where no session was confirmed.
- **New refusals or settings:** none.

## Proof

**After the main merge and review50-r2's N1** (head `e8e5805a`; merge `89a20fc7`, then the N1 commit):
- **RED:** the pre-N1 read-back with the N1 tests fails 3: unconfirmed versus not-created, and the real tmux case.
- **GREEN:**
  - 31 tmux and launch files (599 tests), including #1907's credential and argv files.
  - 33 more files that create sessions through a real adapter: 659 pass. 5 Pi native "runner ready" timeouts fail identically on main
    `e3ee9e0c`.
  - Daemon `tsc` is clean.
- **Not separately run:** the intermediate merge commit; only the head was tested.

**Before the merge:**

- **RED** (`tmux.ts` at main, the new test file): the no-pane, no-server case and the real tmux case both read `ok: true`. Main's file was
  restored afterwards and confirmed with `cmp`.
- **GREEN:**
  - The new file: 4 tests, including the real tmux case, which is skipped on Windows, without tmux, and as root.
  - The adapted `tmux-adapter` and `tmux-argv-exec` files: 158 in total with the new file.
  - The other 27 tmux and launch test files pass.
  - 33 more files that create sessions through a real adapter: 659 pass. 5 Pi native "runner ready" timeouts fail identically on main
    (environment).
  - Daemon `tsc` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 07:51:40 -07:00
Mike Schwarz c0015db579 docs(skill): a queue create's request ID is not the row; proven 502 refusals wrote nothing (#1909)
## What this changes

The openrig-user skill's "After a queue write" section, and the regenerated skill digest. No code changes.

- **The request ID.** `rig queue create` prints `Queue create request ID: <id> (not proof of persistence)` on stderr *before* it sends the write, so an interrupted create can be reconciled. The section now says:
  - that ID names a request, not a row;
  - the row is the `qitemId` in the answer on stdout, and a non-zero exit means no row was confirmed;
  - don't merge stderr into stdout and keep the first `qitem-…`.
- **Proven refusals.** The section listed every 502 `remote_queue_write_failed` as indeterminate. A 502 whose `failureClass` is `unknown-host`, `unsupported-transport` or `registry` is refused before any host is contacted and writes nothing, which is what the CLI already reports ("No row was created"). `unsupported-transport` means the host entry is SSH-declared; `rig host list` shows its http entry.

## Why

Queue writes to another host go through the selected daemon, which forwards them only to an http-transport registry entry. For an SSH-declared entry it answers 502 `unsupported-transport` before contacting the host, and nothing is written anywhere (`queue-cross-host-create.test.ts`, "ssh host: unsupported-transport (502), NOTHING written locally").

The CLI reports that honestly: the refusal, exit 2 and "No row was created". But a caller that runs `… 2>&1 | grep -oE 'qitem-[0-9a-z-]+' | head -1` keeps the request ID, which is printed first. It drops the refusal, and the pipeline's exit status hides exit 2. The caller then holds an ID for a row that doesn't exist. The skill is where agents learn how to read a queue write, so this is the place to say it.

## Checks
- `node scripts/mirror-skills.mjs --check`, `node scripts/generate-context-packs.mjs --check`, `node scripts/check-docs-guard.mjs` and `node --import tsx scripts/check-first-run-references.mjs`: pass.
- `node --test scripts/mirror-skills.test.mjs`: 34 passed.
- Vitest, `--maxWorkers=2`:
  - daemon `openrig-core-plugin`, `queue-cross-host-create` and `queue-cross-host-handoff`: 117 passed;
  - CLI `queue-host-refusal-hint`: 26 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 07:50:55 -07:00
Mike Schwarz 92a052d55d fix(daemon): OpenRig's own rows wake from OpenRig; transport identity not called authenticated (#1905)
## What and why

An outside audit raised two questions about labels (credited in #1918, from Heretek-AI/heretek-rigs#81 part b).
**1. The stuck sweep's recovery rows look like they came from a person or seat that never sent them.**
- **The cause:** a recovery row's source is the stuck row's creator (`queue-stuck-sweep.ts`, "the obligation's own creator is the finding's source"). `create()` staged the wake with that source as the envelope sender, so the seat saw "From: <creator>" and "↩ Reply: rig send <creator>". That creator never sent it.
- **The change:** a row OpenRig creates itself (`wakeTrigger: "openrig"`) now wakes from `daemon@kernel`, the scheduler's existing sender name. The envelope offers no reply for OpenRig's own senders; it says "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
- **Unchanged:** the row's `source_session`, its admission, and its urgency marking. A source that is already OpenRig's (`daemon@kernel`, `system:health`) keeps its own name.

**2. `identityProvenance: transport:v1` reads as stronger than it is.**
- **What it records:** the label is accurate. It means the name came from the `X-OpenRig-Session` header, which the CLI sets from the seat's `OPENRIG_SESSION_NAME`, rather than from a body claim. The reference docs already say so.
- **What overclaimed:** two user-visible `actor_required` messages said "no authenticated transport identity", and several comments said the header "proved" or "certified" the actor.
- **The change:** those are reworded, and `coordination-primitive.md` gains one sentence saying it isn't authentication.
- **Unchanged:** the label, the stored values, and every refusal.

## Rows that change wake sender

| Creator (`wakeTrigger: "openrig"`) | Row source | Wake sender before | After |
|---|---|---|---|
| stuck sweep finding | stuck row's creator | that creator | `daemon@kernel` |
| workflow exception escalation | instance creator | that creator | `daemon@kernel` |
| health diagnosis, periodic | `system:health` | `system:health` (with a reply line) | `system:health`, no reply line |
| health diagnosis via `rig health diagnosis evaluate --apply` | the seat that ran it | that seat | `daemon@kernel` |
| continuity cutover baton (watchdog) | the registrant | the registrant | `daemon@kernel` |
| mode scheduled end | `daemon@kernel` | `daemon@kernel` (with a reply line) | `daemon@kernel`, no reply line |

**Why the relabel sits in `create()`:** at wake time, trigger `openrig` also covers boot recovery and ladder retries of rows that agents created. Relabeling there would hide real senders; only `create()` knows that OpenRig itself made the row.

**What counts as OpenRig's own sender:** `daemon@kernel` and `system:` actors. Neither can be a seat's name (`{pod}-{member}@{rig}`). `…@system` isn't matched, because a rig may be named `system`.

## Cost

| | Lines |
|---|---|
| Source, net vs main | +42/-29 |
| of which behaviour: `queue-repository.ts` | +6/-3 |
| of which behaviour: `pane-envelope.ts` | +9/-1 |
| of which wording only (7 files) | the rest |
| Tests | +42/-1 |
| Docs | +3/-1 |

- About 1 test line per source line.
- **New refusals:** none. **New error codes:** none. **New settings:** none.

## Proof

- **RED:** the new test files were run against main's three source files (`pane-envelope.ts`, `queue-repository.ts`, `require-sender-identity.ts`), with everything else at this head. 5 cases fail. The recovery-row case received `From: peer@test`; the envelope case received `↩ Reply: rig send daemon@kernel "..."`; both message cases received "no authenticated transport identity"; the held copy was `senderSession: peer@test`. The fixed files were restored and confirmed byte-identical with `cmp`.
- **GREEN, daemon:** 23 files, 911 tests at `--maxWorkers=2`. In the first run, 910 passed and one new case failed on the test's own mistake: a peer's routine wake is held, not typed, at that level. I fixed the test, and its file then passed alone, 21 of 21. The set:
  - the four changed test files;
  - stuck sweep (both), workflow exception (four), health diagnosis, continuity materializer, mode scheduled end;
  - wake ladder, held-wake escalation, parked-owner consumer;
  - local-bare-identity, self-host envelope (triple and sweep), session-transport, queue nudge summary, transactional closure, s02-p3-sender.
- **Typecheck:** daemon `tsc` is clean.
- **CLI:** send, send-header, client-headers, self-host-triple: 129 tests.
- **Repo:** `npm run test:repo` 309 pass, 0 fail.

## Base

Built on `26d629e3`. Main has since gained only #1903, which touches `queue-stuck-sweep.ts` and its test; there's no overlap with this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 07:45:45 -07:00
Mike Schwarz 7923e13e35 fix(daemon): avoid discarded health diagnosis census projections (#1920)
A malformed structured field on an unrelated row tagged health-diagnosis could prevent diagnosis enumeration. Read only matching IDs for the preliminary census, then read each owned occurrence fresh. Keep exact tag selection, newest-created ordering, and the 10,000-row refusal before filtering for ownership.

The preliminary census intentionally stops producing discarded row projections and their incidental activity effects and errors. Owned validation, custody, and human-delivery receipts retain their existing reads.

Validation: the regression fails on the base; implementation and independent QA each pass all 24 affected tests, and daemon typecheck passes. No production speedup or stall-cure result is claimed.
2026-10-24 07:31:48 -07:00
Mike Schwarz cd1074651c test: preserve team identity on daemon restart without tmux environment (#1904)
Extend the installed daemon-restart scenario with restarts that drop inherited TMUX/TMUX_TMPDIR and the test scaffold's forced-socket wrapper. It checks that the same two team seats, processes, sessions and bindings survive, with no new panes on the default socket and no superseded sessions.

The sequence (extended for #1906's option (a)):
- **First blind restart:** the team keeps running.
- **Second blind restart, after a real identity poll records a verdict with no pid:** the team still keeps running.
- **Recovery:** after a restart that can see the seats' server, a fresh sent marker reaches the pane.
- **Gone control:** before the kill it records the owned seat's processes (the pane shell, its child and the recorded pids) and rechecks the pane's identity. It then closes the owned pane with `tmux kill-pane`, since SIGTERM doesn't end an interactive shell. Every recorded pid must answer "No such process", and that seat must detach while the other stays running and untouched.

Scope: team seats under the existing `--no-kernel` helper; kernel duplication isn't exercised. The existing preserve/failed-backup and MCP checks stay in the same case. tmux fields use a literal `:` separator, because tmux without UTF-8 or `TMUX` renders a tab as `_`. Required result checks and negative controls cover missing or replaced seats, changed bindings, superseded sessions, duplicate panes, a retained wrapper, a changed fixture identity and a surviving process.

Validation: 23 local result tests pass (synthetic proofs; the mock renders tabs as tmux does without UTF-8, and models a surviving shell). `installed-scenario` passes in CI at this head on main with #1906 and #1907, with `daemon-restart-preserves-team-identity` PASS. Installed runs on the published baseline and candidate archives remain separate validation. No native or model calls.
2026-10-24 01:49:55 -07:00
Mike Schwarz e3ee9e0c6e fix(tmux): keep seat credentials out of command lines (#1907)
Seat launches, handovers and one-off environment cleanup keep provider credentials and the default activity-hook token out of process command lines. Environment-bearing tmux commands use create-only mode-0600 files. tmux-specific quoting preserves multiline values, indentation and comment-like text. Scoped shell resets use builtin exports from a private bootstrap. Managed Claude keeps its existing environment allowlist using builtin exports and removal names, with a short child of the daemon's Node parsing NUL-delimited input under an empty environment.

Validation: synthetic runtime REDs reproduce multiline corruption and managed-Claude argv exposure. The pre-PR tmux byte comparator passes all three native launch modes. Final affected suites and daemon compilation: 33 files / 620 tests pass on macOS with tmux 3.6a; daemon compilation passes. The follow-up startup-suite correction moves five startup tests from the old argv to the private launch config, keeps every value assertion and adds argv-absence checks; 648 affected tests pass. No provider/model calls. Native Windows/psmux and full daemon/repository suites were not run locally.

Cost: 11 files, +313/−102; two production files, nine test files, no dependency or setting. Managed launches add one short Node child and use the platform env utility's -0/-u options. Failed staging stops rather than reverting to credential argv. The scoped bootstrap is single-use; OpenRig restarts and handovers supply a fresh command.

Limits: 0600/wx protects creation; later reads use the pathname and trust a protected temp directory and its parent ancestry. A replaceable, non-sticky parent can permit substitution. Failed/uncertain operations or failed unlink can retain owner-only credential-bearing files; failure cleanup is not complete.



Requires an `env` that supports `-0` and `-u`: GNU coreutils and macOS 15 were checked; busybox and older macOS are unverified.
2026-10-24 01:12:28 -07:00
Mike Schwarz 991ed0dca9 fix(daemon): don't detach seats that still run on a tmux server the daemon can't see (#1906)
When the daemon is started without the seats' TMUX (another shell, a service manager, an upgrade script that trims the environment), it looks at a different tmux server and sees none of its seats. Startup used to detach them all, classify the kernel as lost and relaunch the rig, leaving the person's running agents unmanaged beside new duplicates.

A session that isn't visible is now kept when the seat's last observed process for that same session name is still running and started no later than that observation. It's reported in ReconcileResult.unseen with the process's immediate parent, the kernel classifies as live, and startup logs one warning per seat naming the process, its parent's command line and the fix (restart the daemon inside the seats' tmux server, or with TMUX set to it).

Migration 135 adds evidence_pid, evidence_pane_pid and evidence_at to seat_identity_verdicts, backfilled from verdicts that carry a pid. The store's upsert refreshes the evidence from any verdict that observes a process, keeps it through verdicts without one for the same session, and clears it for another session. The current verdict is unchanged, so the restarted daemon's own blind poll records an honest null-pid verdict without erasing the evidence, and a second blind restart holds too.

Tests: unit (injected process table: running process, server without the session, reboot, pid reuse, unreadable table, earlier session, second restart after a real null-pid poll, recovery on the correct server), native (a real tmux server on its own -S socket and a daemon environment with no TMUX), store evidence (kept, refreshed, cleared, backfilled) and a kernel-lost-restore case. RED on the base and at 95ce152f (exactly the two second-restart cases); GREEN at 01d6752c on every CI job.

Limits: the pid check is a conservative hint, not process identity (whole-second start times). Evidence is only the last observed process. A surviving orphan can keep a dead agent's session running until a later startup or recovery. One retained kernel session suppresses automatic lost-kernel restoration. A daemon on the wrong server still can't operate those seats until it's restarted where it can see them.
2026-10-24 01:01:21 -07:00
Mike Schwarz 75c1f04563 fix(daemon): isolate stuck-sweep finding failures (#1903)
A stuck row addressed to a removed rig could abort the sweep before unrelated rows received recovery findings. Isolate each finding create so later candidates continue, keep still-detected conditions unresolved, and report the pass as failed with its acknowledged partial progress.

Validation: the base fails the later-valid-recovery fixture; implementation and independent QA each pass 132 focused tests, and daemon typecheck passes. Existing custody and dedup controls pass. The local repository fixture does not establish production incident causality. Known diagnostic limit: a create error after persistence can still be labelled 'not created'; existing recovery lookup can find the persisted row on a later pass.

Cost: source +17/-2 (1 file), tests +33 (1 file). No new API, refusal or routing fallback.
2026-10-23 22:33:41 -07:00
Mike Schwarz 26d629e38d fix(slack): retain absorbed answer event identity across replay (#1898)
A typed Slack reply absorbed into an existing button or Steering answer stays absorbed when Slack replays it after an interrupted close and final seen append. The replay returns the original answer instead of sending words the person was told were not sent. A different later event remains conversation.

Record the absorbed event, ask, person and original answer in the existing seen log before closing the ask. Keep the final event marker after close so an interrupted close can still finish. Cost: one extra durable log record for an absorbed event; no schema change.

Validation: both button and Steering cases reproduced the duplicate before the fix; all 21 regression cases and 166 affected tests pass, as do daemon compilation and TUI typecheck. Synthetic migrated SQLite and real router/answer routes with recorded transport nudges; no live Slack or model execution.

Follows #1896, merged as dacd498e. This change is the delta after c9c7344d.
2026-10-23 21:42:11 -07:00
Mike Schwarz 684c1354ae fix(watchdog): release obsolete schedule history (#1900)
Updating and then stopping watchdog jobs left their replaced schedule YAML retained for the repository lifetime. Release that history after completed owned delivery accounting, or successful retirement with no owned delivery. Live delivery pins, durable records, receipt order and unaccounted retry state stay intact. The existing eight-plus-pinned-start bound is unchanged.

Validation: the stop-retention case fails on the base; 148 focused tests passed in both implementation and independent QA runs, and daemon typecheck passed. Map assertions observe stop and held-completion release. Terminal/generation disposal is supported by source review and existing behavior controls, not a dedicated new disposal assertion. The reviewed contract is the existing serial scheduler; arbitrary overlapping direct evaluations remain outside it.

Cost: source +13/-1 (1 file), tests +33 (1 file). No new API, refusal, error code or timer.
2026-10-23 21:36:34 -07:00
Mike Schwarz 8165e03c64 fix(send): a terminal seat gets the exact text, never the From/To envelope (#1100)
A terminal member, a seat that runs a plain shell or program instead of an agent, now gets a deliberate rig send's exact text. The agent-style From/To envelope, and an urgent send's 'Urgent (marked by ...)' line, would otherwise be typed into the shell and run. The urgent mark still admits the input and stays in the result.

A queue reminder to a terminal member types nothing; the task stays queued with a terminal-seat reason instead of being typed into the shell.

OpenRig's own notices carry an internal marker. Holding them for terminal members by default is the seat interruption levels' work, not this change. Explicit --dangerously-interact sends are unchanged and still read the composer first.
2026-10-23 21:15:38 -07:00
Mike Schwarz 6200b3d4be feat: optionally stop daemon startup on a confirmed database conflict (#1890)
An optional stop for two OpenRig daemons sharing one database. With daemon.db_conflict=stop, rig daemon start stops before the database opens when another running OpenRig is confirmed to hold it (an open-file holder, a matching daemon.json and listener PID), names the reason, and offers a one-start go-ahead: rig daemon start --allow-db-conflict. The default stays warn, with today's warning text; an explicitly selected database outside OPENRIG_HOME alone only warns, and an unknown holder never stops.

The go-ahead flag applies only to that start's child and isn't saved. An exported OPENRIG_ALLOW_DB_CONFLICT=1 still affects later starts from that shell, and an inherited policy value overrides the file; the docs say so. daemon.db_conflict is read at boot, and rig config set notes that.
2026-10-23 21:05:58 -07:00
Mike Schwarz dacd498e79 fix(queue): reuse human answers across Slack and Steering retries (#1896)
Retrying an answer from Slack or Steering/TUI after an interrupted close now reuses the first committed reply, finishes the request once, and names where the original answer went. Before, the two surfaces could each create a reply and wake the asker twice.

While an ask is still pending, the first committed answer wins and a second typed message is treated as a retry. Once it closes, a later typed Slack message is ordinary conversation again, and a replay of the typed event that produced the answer reuses it. Legacy reply ids and attribution stay readable; wrong-person replies stay correspondence; reactions and recovered late messages stay separate. No schema, setting or error code is added.

One narrower case remains for a follow-up: if a button or Steering answer's close is interrupted, a typed retry then completes it, and that retry's seen record is also lost, a replay of the typed retry can still arrive as a second reply.
2026-10-23 21:00:13 -07:00
Mike Schwarz f177ecd0bc fix(stream): retention keeps history that existed before the upgrade (#1897)
Stream retention no longer deletes history that existed before the upgrade. Migration 131 records a floor, the newest stream item present when it runs, and both the age prune (30 days) and the 20,000-item cap apply only to items above it. A fresh install has no floor and prunes as designed. This corrects #1891, whose first sweep after upgrade would have deleted existing unreferenced items older than 30 days.

Referenced-item protection, the gap state, the 410 for an expired list cursor and the batch bounds are unchanged. No new setting, refusal or error code.
2026-10-23 20:36:45 -07:00
Mike Schwarz 981b6bcbbb fix(send): show existing input before deliberate overrides (#1892)
An explicit rig send --dangerously-interact no longer types over text a person has already typed. Before pasting, it reads the target's composer under the existing lease and final target check, on every override path, including a broadcast that waits for idle. Typed or pasted text stops the send, and the caller is shown that text, apart from any suggestion (override_input_present). Input that can't be read stops with its reason (override_input_unknown) and is never treated as empty. An empty box, or one holding only a suggestion, types as before, and a recognised native prompt is still driven.

--type-without-checking skips the read for one send, says so, and clears nothing. To clear on purpose, use the native or tmux controls and send again; that clear happens outside OpenRig's checks and isn't recorded. Reading and pasting aren't atomic: a keystroke can land in between. The shown text goes only to the caller, never to the receipt, outbox, held messages, capture observer or audit.

Ordinary sends, notices, wakes and the guard-off default are unchanged. The openrig-user skill now teaches the stop and the deliberate clear.
2026-10-23 20:26:09 -07:00
Mike Schwarz 7975857f16 feat(stream): one transition with retained custody (slice 22 increment 4) (#1894)
rig project wake --decide now finishes work that was already accepted even when decisions are turned off: it applies accepted-but-unapplied effects under the classifier lease with no model call, so an accepted create or join never stays at effect_pending. Without the lease or a daemon it says so and leaves the effects waiting.

The automatic path skips items that intake already abstained on under another version, except experiment versions; this needs the updated daemon (an older daemon ignores the new parameter). rig project wake --experiment stays and records its attempts as <version>+experiment, so rig stream show labels them experiment and they never block --decide. The kernel queue worker's guidance and the reference doc now describe the --decide path and the disable boundary.
2026-10-23 20:19:55 -07:00
Mike Schwarz 030defd23d feat(stream): hub readers subscribe and read bounded pages; capped bodies and stream retention (slice 22 9a) (#1891)
Reader seats can follow the stream without reading it all. rig stream subscribe <seat> saves a filter (by default, items addressed to the reader or tagged with its session, seat or logical id; --tags and --type override it) and a cursor the daemon keeps across compactions and handovers. rig stream read returns what's new after that cursor as a bounded page: at most 20 items, 2,000-character excerpts, and the whole serialized page under 64 KiB, with clipped metadata marked. If retention pruned past the cursor, the page says so instead of looking complete. rig stream subscriptions lists each reader's unread count and size.

A body over 16 KiB is stored truncated with a marker, and its kept and original sizes are shown by read, show and list. Nothing is refused.

Stream retention keeps at most 30 days or 20,000 unreferenced items (retention.stream_days and retention.stream_max_items); items with a classification, attempt or disposition are kept. Upgrade effect: retention is on by default, so the first sweep after upgrade deletes existing unreferenced items older than 30 days, and a downgrade can't restore them. A rig stream list --after cursor whose anchor was pruned now returns 410 stream_cursor_expired with the pruned position, instead of an empty list.

Migration 130 adds the subscription and retention-mark tables and two nullable size columns on stream items.
2026-10-23 19:33:07 -07:00
Mike Schwarz 130b5ba638 fix(setup): kernel boot honours rig setup --providers (#1817)
rig setup --providers now saves the choice as provider.selected (claude, codex, or claude,codex; empty when no choice was recorded), and kernel boot honours it: a provider the person didn't choose is treated as unavailable before the team is picked. Before this the choice wasn't saved, so the kernel picked its team from the sign-in check alone, and a signed-in but exhausted Claude account gave the person Claude seats.

With no choice recorded, kernel boot works as before. A kernel that already exists keeps its seats; setup says so, and says when OPENRIG_PROVIDER_SELECTED in its environment takes precedence. The kernel operator's provider step reads rig config get provider.selected first.

One new stop: with only Codex chosen, Codex signed out and Claude signed in (or the reverse), kernel boot stops with auth_blocked, naming the choice and its login command and saying to rerun setup with --providers to change it, instead of starting a kernel on the runtime the person didn't pick.
2026-10-23 19:01:19 -07:00
Mike Schwarz 1c6981ce0b feat(stream): rig stream show says what became of an observation (#1888)
rig stream show <id> now says what became of an observation, derived each time from existing records: its disposition (from rig project wake --decide), its classification, and the linked queue row.

The stages are awaiting intake, deferred to intake, decided by intake, no action, duplicate, effect pending, with intake, work open and work closed. A handed-off or superseded row is followed to its live successor (up to 8 hops); a failed read or an unlinked handoff reads unknown, never closed. A closed row shows its closure, resolution and evidence, with the note that closing isn't proof a fix shipped. Against an older daemon the outcome says the records aren't there.

Read-only; the one daemon change is an additive streamItemId filter on the existing classification list.
2026-10-23 18:42:29 -07:00
Mike Schwarz 643a96b068 fix(compaction): a preparation prompt that never reached the seat is not sent (#1889)
A consistency fix. When the managed-compaction preparation prompt's send result says inputPhase "not_reached" (nothing reached the seat), the enforcer now treats it as not sent, as the /compact send already does. An automatic attempt goes back to sending, bounded by PREP_SEND_ATTEMPTS_MAX, and a manual request stops at once with the hold's reason, instead of waiting out the 25-minute deadline as uncertain.

On a real draft-aware seat the held prompt was already retained and not sent on main; a new control case pins that. This change covers a raw not_reached result reaching the enforcer without draft-aware delivery.
2026-10-23 18:26:08 -07:00
Mike Schwarz 709a464d92 test: require version advances for changed bundled plugins (#1885)
A release-preparation check that changed bundled hooks and skills will reach existing installs. Managed refresh replaces installed plugin files only when the bundled version is strictly newer, so changed files at an unchanged version silently stay as they were. Run node scripts/check-packaged-plugin-versions.mjs candidate.tgz on the exact release-candidate archive: it compares the packaged plugins with the last release's (additions, removals, bytes, leaf-link targets), requires a strictly higher numeric version when anything changed, requires consumed manifests to be regular files and harness versions to agree.

The baseline is published 0.7.0's 53 plugin files. --snapshot refreshes it from a newly published archive, writing to a temporary file first so a failed run keeps the old baseline. The check isn't called from per-PR CI, so contributors don't bump plugin versions for individual edits. It runs on our own release-candidate archive and isn't hardened against a crafted one.
2026-10-23 18:17:48 -07:00
Mike Schwarz d9ed965d12 docs(roadmap): add the planned 0.7.1 section (#1887)
Docs only. ROADMAP.md gains a "Planned for 0.7.1" section: team skills through one router that stay out of your project; per-seat interruption levels with presets for managed compaction and model or effort changes; each seat's planned and current permissions; previewing and installing bundles that run services, off by default; subscriptions, if ready; and community contributions to launch, lifecycle, runtimes, observation and delivery.
2026-10-23 18:13:35 -07:00
Mike Schwarz 107dcd2aa6 test: make Claude footer readiness waits deterministic (#1886)
Test-only. The transport-wait cases in claude-warning-footer-readiness.test.ts injected a fixed hook clock and a no-op sleep, but waitForIdle used Date.now and a real timeout, so under runner load a valid running observation could miss its 20 ms window and read no_observation_in_time. It failed one daemon CI run on 2026-10-23.

The waits now use a fake Date and setTimeout advanced only by the injected clock, so the result no longer depends on machine load. Every case keeps its assertion. A forced 30 ms delay in fixture capture fails the old fixture and passes with the fix; the whole file passes.
2026-10-23 18:10:21 -07:00
Mike Schwarz c9088cc055 docs: as-built CLI notes and openrig-user MCP wording for batch3 D-2 surfaces (#1883)
Docs only. cli-reference.md and openrig-user's SKILL.md now describe what 0.7.1 adds or changes, as the code does it:

- gateway human slack-subscription: set every-ask, urgent-only or unset; show prints the value and whether it's authored or the default.
- ps --nodes --json: the compact projection keeps the served needsAttention; arbitratedNeedsInput and needsInputEvidence come with --full or --fields.
- compact-plan: its flags are the plan's own thresholds, and the output shows policies.claude_compaction separately.
- The send guard at a complete, current Claude MCP approval menu: the send refuses with target_needs_input, and --dangerously-interact --reason is the only override.
- Startup attention: after a refused Claude startup turn, later inputs are held and counted; Cursor and other community-harness assisted startup is reported honestly.

The skill's MCP section also stops overstating three things: a missing or unreadable grant file is read again on every request and recovers without a restart; --owner offers every tool, not only --workspace; and several tools beyond the queue tools and rig_whoami return structured results.

Three files, +26/-5, including the skill's one digest entry. The PR body cites each change to source at the base and lists what was left unchanged and why.
2026-10-23 17:50:47 -07:00
Mike Schwarz 94942509ae fix(transport): label draft refusals not_reached; a draft hold of /compact still stops (#1884)
When the typing guard holds a send because a person is drafting, the send's result now says nothing reached the seat: inputPhase "not_reached", as a managed-compaction hold already did. The guard's draft checks all run before the paste, so no target input is attempted. The reason, error text, sent: false and every guard decision are unchanged; a generic failure stays "attempted", and a submit refused after a successful paste stays "written" with sent: true. Callers such as the identity-hint warning can now say "not typed" for these refusals rather than "could not establish".

The compaction enforcer read not_reached as "re-arm and retry". It now excludes draft refusals narrowly, so a draft hold of the managed /compact still stops as not sent and starts fresh after its cooldown, as before. Genuine idle deferral, including the reason-independent control, is unchanged. Cases cover busy, unknown and changed draft refusals and a refusal clearing on the next poll before cooldown.
2026-10-23 17:37:36 -07:00
Mike Schwarz 228ef831b3 feat(stream): the intake seat routes stream observations with typed decisions (rig project wake --decide) (#1882)
rig project wake --decide: during the intake seat's own wake, each eligible stream observation gets one bounded typed decision (#1879's component and the shipped stream template), and routine cases reach owned work without a full intake turn. Decisions stay off unless enabled.

- create opens the chosen supplied owner's row with a derived id; join adds an association note (not a wake or reopen) to supplied work; duplicate (which needs supplied work) and no_action are recorded with no queue effect.
- Anything uncertain, refused, malformed, stale, failed or not called is deferred to intake as its own disposition, with the request, template and returned provenance; the agent can classify it at once, and the model isn't asked again. Only a confirmed missing disposition permits a new call.
- Accepted effects keep their accepted source and fixed id; recovery applies them without new inference, and a different holder reconciles a matching row or hands the item to intake. Application carries the exact lease and occupant through each queue write, via an optional classificationBinding that ordinary queue calls don't carry.
- Migration 129 adds stream_dispositions, one row per stream item, inserted with the classification.

New refusals, all on this opt-in path: invalid_disposition (400), disposition_not_found (404), disposition_exists (409), decide_usage (400), invalid_classification_binding (400); stale bound writes reuse the existing lease and occupant 409s. Known limit: two concurrent wakes from the same valid lease can append a join note twice.
2026-10-23 17:26:22 -07:00
Mike Schwarz 6bd388e25c test: pin refocus fallback case budget to 52000 ms (#1881)
Test-only. The refocus content-ref fallback case asserted that both optional lookups run, but at the default 4500 ms budget the hook skips them once process uptime passes about 250 ms, which a loaded CI runner reaches. That skip is the hook's designed degradation, so the case failed at random (one daemon CI run on 2026-10-23).

The case now pins OPENRIG_REFOCUS_BUDGET_MS to 52000 ms, the supported maximum, so it tests the fallback logic rather than runner load. This is a test budget pin, not a race fix. The four-verb assertion is unchanged, and no product default changes. An injected delay at hook start fails the case on the old env and passes with the pin.
2026-10-23 16:28:33 -07:00
Mike Schwarz 23cfa635a8 fix(claim): describe identity-hint send failures by what was established (#1880)
The claim identity-hint warning said "not typed" for every failed transport result. The transport only establishes that nothing was written when inputPhase is not_reached: attempted may have landed, written text may sit unsubmitted, and a result without a phase doesn't say.

This changes only the warning text in ClaimService.deliverClaimHint. not_reached keeps "identity hint not typed into <session>"; written (or sent: true) says the text was written and the submit isn't confirmed; attempted says it may have been typed; no phase says it couldn't be established. The transport's reason and error are still included. Delivery, guards, hint routing and transport outcomes are unchanged. Four cases cover the evidence states; the three non-not_reached cases fail on the old wording.

Known limit: pre-write refusals that return sent: false with no inputPhase now read "could not establish" rather than "not typed"; giving those transport paths inputPhase: not_reached is separate work.
2026-10-23 16:17:05 -07:00
Mike Schwarz e3704f1421 feat(decisions): bounded typed decisions over OpenRouter + Jev, off by default (#1879)
A reusable decisions component in packages/cli/src/decisions/, the first increment of the stream-routing work. It's off by default and has no production caller yet.

A caller passes a named template (instructions plus a typed answer contract) and bounded state, and gets one result: answered, abstained, refused, malformed, stale, failed or not_called. A provider error is failed, never an abstention; off, unconfigured, an unknown consumer or a missing key make no call. One run owns the call count, total deadline and concurrency, rechecks its settings and template before every send, and halts after a timed-out or cancelled call that may still be running. Every sent result carries the exact request (never the key) and the template's text and digest; returned provenance is kept on failures too.

The key comes only from OPENROUTER_API_KEY in the invoking process and goes only in the Authorization header. Settings: decisions.enabled (default false), decisions.consumers and decisions.template_dir (absolute only). A stream template ships with it. rig project decisions status shows the state without making a call. The call copies the existing experimental Jev client rather than reusing it; the two converge when that path moves onto this component.
2026-10-23 16:05:50 -07:00
Mike Schwarz 1cb2ed90cd feat(gateway): the first attention subscription decides whether plain asks post to Slack (#1878)
Every ask to a person is on Steering and the TUI. Whether it also posts to Slack is the person's Slack subscription, the first attention subscription: a saved filter plus a destination on their gateway record (subscriptions: [{ destination: { connector: slack }, filter: { preset } }]).

- every-ask, the default: every ask posts to Slack, as on 0.7.0. Nothing changes on upgrade.
- urgent-only: only an ask marked --alert posts; the rest wait on Steering and the TUI (#1868's behaviour).

Set it with rig gateway human set <id> slack-subscription every-ask|urgent-only|unset; rig gateway human show prints it and whether it's authored or the default. --alert decides per item: a handoff doesn't inherit another ask's alert, and rig workflow route keeps a person's alert-worthy failed-step approval alert-worthy. The record's keys are closed and the preset must be a plain string, so a malformed entry is refused instead of silently quieting Slack. The skill, --alert help, the MCP text, --verify's not-alerted wording, the Feed and the Slack setup page use the subscription framing. A filter language, channel destinations and UI come later from the same record.
2026-10-23 15:07:40 -07:00
Mike Schwarz c3c8b79043 feat: serve daemon attention and evidence on seat inventory (#1876)
Each seat's attention now comes straight from the nodes list, decided the way the rig's attention total decides it, instead of each reader copying its own rule.

- The nodes list and detail serve needsAttention from the same predicate as the rig's attentionCount, plus arbitratedNeedsInput (true, false or null) and needsInputEvidence (rung and time, or null). The existing predicate moves from ps-projection to the inventory, with the old exports kept. No new endpoint, probe or arbitration engine.
- The CLI drops its copied rule. Compact human output adds an ATTN column (yes, no or ?) before REASON, so REASON now starts at column 104 instead of 99 and is the seventh field; compact JSON keeps the served flag, and full output shows the provenance.
- The TUI's hydration, graph glyphs and host attention totals use the served flag, and the seat detail shows the evidence on its activity line, labelled by its actual source: hook, pane or unknown. A hook-only prompt reads needs_input.
- Mixed versions: when an older daemon doesn't serve the flag, it reads as unknown, not false. The TUI then keeps its previous status and glyph from the older fields, and compact CLI keeps the diagnostics (latestError, the activity reason) that daemon already serves.

0.7.1 slice 06 outcome C. A false result means no known trigger, not proof that no unobserved input is waiting.
2026-10-23 14:55:52 -07:00
Mike Schwarz 931395966a test(daemon): the held-preparation manual-compaction tests don't spend their 50ms budget on wall time (#1877)
Test-only. Three manual-path tests in claude-compaction-enforcer.test.ts hold the first preparation open across later calls: the concurrent same-seat guard, the state-surfacing test and the DEGRADED duplicate test. Their fixture gives a manual preparation 50 ms on the real clock, so on a busy CI runner the held attempt could pass its deadline and be stopped as preparation_incomplete, and the test then saw no /compact. It failed one daemon CI run on 2026-10-23 and once earlier.

Each now injects a fixed now(), as the file's wait-for-idle ordering test already does ("busy CI scheduling must not consume its 50ms budget"). The fixture's default clock is unchanged, since other tests rely on a real deadline expiring. Shown by stretching the hold (80 ms: fails before, passes after) and repeated runs.
2026-10-23 14:54:22 -07:00
Mike Schwarz 4614dd4b00 test(daemon): the move-report read case waits a millisecond between creates whose order it reads (#1874)
Test-only. The subscription-move routes test ("reads reports newest first, chains follow-ups...") could fail when two items were created in the same millisecond: queue list() orders by creation time with no tiebreaker, and SQLite then returns a same-millisecond pair in insertion order, oldest first, while the test expected newest first. It failed one daemon CI run on 2026-10-23.

A nextMillisecond() helper now waits for the clock to move on before the later item of each pair (the second filed report, and the second hand-made unreadable item), so the newest-first assertions keep their meaning. The product's order is unchanged; follow-ups chain by id, not position. Shown by forcing the tie (Date frozen: fails before, passes after) and 20 unchanged copies passing after.
2026-10-23 14:29:31 -07:00
Mike Schwarz f393e2e346 docs(cli-reference): add rows for 13 registered command and flag groups (#1875)
docs/as-built/cli-reference.md, the reference agents are pointed to for every command and flag, had fallen behind the registered CLI. This adds or corrects the rows for 13 command and flag groups, each checked against its source line:

- rig community-harness (list --json);
- the rig gateway human quiet group (set with --daily, --until, --tz, --actor, --json; end and clear; show);
- bundle create --include-login-files; down --yes; slack channel-map set --inbound <seat>; capture --history-plus-pane; skill loadout --resend; mode unset --category; whoami --all-peers; provider status --all-hosts and --hosts; rig usage cost (--seat, --json); proof show and proof judge --project;
- mcp token add --seat is marked required.

Docs only. The seat, handover and queue sections, the frontmatter stamp and the counts snapshot are unchanged. Built as cloud batch 2 task D-1.
2026-10-23 14:20:52 -07:00
Mike Schwarz 23469b94ce feat(queue): an ask to a person lands on Steering without a Slack alert unless it's marked alert-worthy (#1868)
An ordinary question, approval or request to a person lands on Steering and the TUI's Feed, where they can see and answer it, and nothing pings them on Slack. rig queue create --alert (and rig queue handoff --alert) marks an ask alert-worthy: something only they can do, or an approval whose wait costs now. That ask is the same item, still on Steering, and Slack posts it once. An answer closes the ask wherever it arrives.

- Classification at create: human-required at RECORD for an ask on Steering, at ALERT with humanAlert: true. Nothing re-decides the level later.
- Slack posts a human-required ask only at ALERT, whatever its posting dial says; the dial still governs updates and notices.
- One attention membership for every reader (the Feed, ?attention=1 and the attention aggregate), independent of delivery, with rerouted asks leaving all of them. The Feed lists an ask's questions and answers, and its Slack line shows the actual receipt.
- The TUI answers an ask the way its asker framed it: Approve for a plain ask; each question with its options, with no Approve while a question is open; or reply <text>. The answer is recorded once on the same row through the existing answer path and returned to the asking seat, as a Slack answer is. A completed answer set whose return was interrupted resumes that return; a retry in different words never closes on words that weren't returned.
- The stuck sweep, wake ladder and waiting view treat a quiet ask as waiting on the person; a quiet handoff isn't escalated to Slack. rig queue create --verify reports not-alerted at once.
- Callers that need an alert set it: host pairing, the health notify path, workflow exceptions routed to a person, and MCP rig_queue_create's optional alert. The messaging-the-human skill teaches both routes.

Limits: per-person preferences beyond quiet hours aren't built, and where a mission-control bearer is configured that differs from the TUI's terminal token, the TUI's answers get the existing refusal.
2026-10-23 13:42:28 -07:00
Mike Schwarz f309deae56 perf(daemon): share raw skill scans within one preflight (refs #1653) (#1873)
A preflight (for example rig up --plan) resolved each member's profile in turn, and each resolution could run the managed skill-catalog scan and, when a named skill wasn't found in its own folder, the full scan of every SKILL.md. So a plan with N members repeated the same file reads N times.

- Within one preflight run, the managed catalog scan and the full skill-scan fallback each run at most once, and every member reuses that raw scan. Which skills resolve, their precedence, rejections and errors are unchanged, and calls outside a preflight run behave as before.
- The named-skill lookup stays fresh per member, so a skill repaired during the run is still seen by later members.
- A transient read failure in a reused scan is cached for that one preflight run only.
- The resolution worker's comment now states this per-preflight reuse instead of fresh filesystem reads for every member.

The benefit wasn't measured. Refs #1653, which stays open for the reporter's rerun. Built as cloud batch 2 task B2-1.
2026-10-23 13:41:33 -07:00
Mike Schwarz cd35c757c5 docs(skills): openrig-user's MCP section matches the shipped server (#1872)
The openrig-user skill's MCP section listed the old 23 tools and rig mcp serve [--port], so agents learning the MCP surface from the skill didn't know most of it existed. It now matches the shipped server:

- all 34 tools, grouped by access, including whoami, restore status, team, slices, mission, context, artifact read, the decision room and decide, and revises, alongside the queue tools;
- rig mcp serve over stdio, and rig mcp serve --http with scoped tokens (rig mcp token add --seat);
- what an agent with no shell can and can't do, and that nothing wakes an MCP-only agent.

Text only. The skill's entry in scripts/skill-edge-digests.generated.json is regenerated for the new bytes.
2026-10-23 13:29:33 -07:00
Mike Schwarz 1f70edb352 fix: submit an owned paste whose collapsed label renders late (#1871)
A draft-aware send can submit its own paste when the collapsed paste label renders after the first observation. An already-recorded label stays authoritative; the new fallback runs only if none was recorded, within the same send and the original composer. Pre-key guard refusals now say Enter was not sent. Refs #14.

- The mechanism (reproduced deterministically with the real guard, tmux adapter, transport and SQLite): right after the paste the composer still reads empty; Claude's collapsed label appears only at the guarded Enter; the old guard refused before sending the key, and later sends then met draft_input_busy.
- Unchanged and still held: a person's genuine draft, a typed edit, a different or same-count replacement paste once a label was recorded, a cursor or frame change, a different target, a later send, and a native Enter whose result is uncertain. There's no repaste, extra Enter, new tmux call or replay.

This proves one failure mechanism, not the reporter's exact native cause. Already-stranded text and uncertain Enter results remain unreplayed, so #14 stays open. If a person replaces the paste before any label is seen, frame and line count don't prove identity; that ambiguity remains. Cost: source +10/-4, tests +73.
2026-10-23 13:13:46 -07:00
Mike Schwarz 2bcee5bc15 fix(daemon): report assisted Cursor startup honestly (#1870)
A Cursor seat whose startup text landed in a collapsed composer no longer reads as ready. It reads attention_required, with guidance to check the composer, submit the staged text if needed, and acknowledge.

- startup_assisted (a new optional preset field, default false; the Cursor example sets it): when that seat's startup sends text, startup ends on the existing attention_required status instead of ready, even if the first Enter worked. The seat keeps running, sends still deliver, and a continuation never pastes the text again.
- The acknowledgement is rig seat clear-attention <seat> --reason ..., and it clears this assisted-startup flag after a full restore too, for that seat's own assisted startup only; Claude and Codex seats keep the strict native identity check. Nothing clears without --reason.
- The launch-time value is kept, so a preset changed or unreadable later can't flip the seat back to ready.
- Every harness without the field behaves as before. An older daemon refuses a preset carrying the new field, as with the existing launch_result and names_conversation fields.
- Docs: the Cursor preset section says unattended startup submission isn't confirmed, a new "Assisted startup" section says what the person does, and a line says an unknown sender header doesn't mean the Cursor seat lost its identity.

Unattended Cursor startup isn't established; native acceptance is a separate check. Cost: source net about +20 in round 1 plus the full-restore fix, tests about 2 lines per source line.
2026-10-23 13:09:47 -07:00
Mike Schwarz 32af64236e fix(daemon): after a refused startup turn, hold the rest of the startup context as owed (#1869)
A Claude seat whose startup turn is refused (an expired login, say) no longer burns the rest of its startup context.

- Nothing more is typed after the refused turn. Between turns the start now reads the runtime's readiness, and a refusal holds every later startup input.
- The start reports the context as owed, never as delivered: the runtime's reason, how many later inputs weren't sent, and what recovers it. The failure carries pending work, so a later reconcile can't mark the seat ready without its context. A refused last or only turn of a fresh Claude start is held the same way, and a later file error can't replace the hold.
- Recovery: a fresh start that typed no one-shot (non-idempotent) action keeps its context pending, and after signing in, rig seat continue <seat> delivers the whole startup again, in order. Where a one-shot may have reached the seat (typed, even if its Enter failed), or a restore fell back to a new conversation, continuing isn't offered and the start names the fresh launch instead. A resumed or forked seat keeps its conversation: the start says it hasn't read the held inputs, with no relaunch and no continue.
- Startups that aren't refused send the same inputs in the same order.

Cost: source +62/-7 before round 2's few lines, tests about 2 lines per source line; no new refusal, error code or state.
2026-10-23 12:59:36 -07:00
Mike Schwarz a2e2a0691a fix(daemon): a daemon restart picks up a managed compaction where it stopped (#1866)
Upgrading OpenRig restarts its daemon. A restart in the middle of a Claude seat's managed compaction could lose its restore (the seat sat idle, holding its work, until someone restored it by hand) or start a second preparation. Now a restarted daemon picks the compaction up where it stopped.

- A record beside each attempt's map (COMPACTION-STATE.json), written atomically by the daemon at every step: the phase (preparing, one of the three post-compact stages, compact_uncertain, done or ended) plus what's needed to rebuild the attempt. Only a record in exactly this daemon's shape is read back, so a newer daemon's record after a rollback can't hold up a seat.
- Recovery, the first time a restarted daemon reaches a seat (the monitor's poll, a manual rig compact, or rig compact --state): an owed stage resumes at that stage; an automatic preparation is waited on with its recorded deadline; a preparation prompt or /compact that was being written is never sent again, and its restore steps are owed; a manual preparation stops as daemon_restarted. It acts only on a record an earlier process wrote, for an unchanged occupant.
- Without a restart, a /compact whose result was lost now owes its restore steps too.
- After a /compact whose outcome is unknown, each restore step goes only into an input box with nothing typed; otherwise it waits, shown as held.
- rig compact --state shows owedRestore from the first look after a restart, and a record older than six hours as stranded, not idle. A later compaction names the record it replaced.

Limits, in the PR body: recovery waits for the first poll, request or state read; a restart between a stage's send and its record can repeat that one prompt; the input box is read once, before the send's idle wait, not at the write; the box reading is a heuristic; a replaced stranded record is remembered in memory only.
2026-10-23 12:48:46 -07:00
taljeri a0526a965f fix(cli): label compact-plan thresholds as the plan's own (#1629)
rig compact-plan said its own fixed triage thresholds directly under the policy line, with no hint that they aren't the automatic compaction setting. Now:

- The human output labels the plan's thresholds as the plan's own; policy.thresholdTokens and policy.thresholdPercent keep their names and meaning.
- It reads policies.claude_compaction.enabled and its threshold, the same setting the daemon's automatic compaction uses, and says whether automatic compaction is on or off, for example "Automatic compaction: off (its threshold setting policies.claude_compaction.threshold_percent is 80%)".
- When the config can't be read, the plan still prints: JSON carries automaticCompactionEnabled and automaticCompactionThresholdPercent as null with automaticCompactionUnavailableReason, and the human output says it couldn't be read and to run rig config. A missing config file still shows the real defaults.

Candidate selection, the plan's thresholds and its read-only behaviour are unchanged. Contribution by @taljeri, with thanks also to @Totopo27, whose #1851 proposed the same fix in parallel.
2026-10-23 12:45:15 -07:00
NIKHIL c5dc0da273 fix(transport): guard sends at visible Claude MCP prompts (#1864)
When a fresh Claude UserPromptSubmit hook reports running, rig send checks current dialogue and composer evidence before sending. A current MCP server-enablement checkbox menu, an AskUserQuestion choice or a permission prompt blocks the send with the existing target_needs_input refusal. Numbered lines left in the pane's history don't block an ordinary send to a working agent, which keeps the busy advisory.

- The current-dialogue check applies only inside the existing fresh running-hook window for claude-code. Stale hooks, idle or unknown hooks, other runtimes and generic probes are unchanged.
- Protected drafts, --dangerously-interact --reason, and the advisory when activity is unknown or the capture fails are unchanged.
- No new refusal, error code or matcher.

Refs #1541. Contribution by @Nikhi00718.
2026-10-23 12:26:11 -07:00
Mike Schwarz 94931b2177 docs(hermes): let a Hermes seat run rig as itself (#1826)
A Hermes seat started from the example preset can run rig as itself, for example to reply with rig send, once its Hermes config.yaml carries one block.

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 listed in terminal.client_env_passthrough. OPENRIG_SESSION_NAME isn't a toolchain name, so without the block rig inside Hermes runs as whichever seat's pane started the gateway, or as no seat.

- community-harness-examples/hermes/config.yaml.example: the block to merge into each seat's config.yaml, listing the seat's own OpenRig variables (OPENRIG_SESSION_NAME, OPENRIG_NODE_ID, OPENRIG_HOME, OPENRIG_URL, OPENRIG_HOST, OPENRIG_PORT, OPENRIG_DB). None is a secret. As a .example, a preset folder never reads it as a preset.
- The example launcher warns on stderr when the seat's config.yaml doesn't pass OPENRIG_SESSION_NAME, and writes nothing to Hermes's config.
- The guide's Hermes section explains the gateway environment and gives the check: ask the seat's Hermes to run rig whoami --json.

No OpenRig code changes. The example test checks the config and the launcher warning against a stand-in hermes; Hermes itself doesn't run in it. Cost: docs and example +33/-1, tests +20/-2.
2026-10-23 12:16:44 -07:00
NIKHIL afc606a8d0 fix(daemon): avoid Claude status line temp collisions (#1865)
Concurrent Claude status-line collectors for the same seat no longer collide on a shared ${outputPath}.tmp. Each invocation stages its JSON through a unique same-directory temp file (.tmp-<uuid>), so one rename can't consume the other's temp file and skip its provider-usage cache write. If the write or rename fails, this invocation's temp file is removed and the original error is rethrown to the existing handler, so a persistently failing refresh leaves no files behind.

Reported in #1861, which the PR closes.
2026-10-23 11:50:25 -07:00
Mike Schwarz ffa4e92283 docs: an OpenClaw example for community harnesses that OpenRig names (#1784)
A documented way to run OpenClaw's CLI as an OpenRig community harness, with OpenRig naming each seat's conversation through names_conversation.

- The example (docs/reference/community-harness-examples/openclaw/): a preset that declares names_conversation (not yet resume), and a launcher that runs `openclaw chat --session <name>` (the TUI with --local) with an OpenClaw state folder per seat. The agent runs in the seat's pane with no Gateway; Gateway-attached use isn't covered.
- The resume check is the launcher's: before reopening a key, it checks that the seat's `openclaw sessions --json` lists it, and exits without starting anything if not, so a gone conversation isn't recreated under its old key.
- The preset's limits: OpenClaw's automatic session.reset must stay off (with it on, a key no longer names one conversation), and /new in the pane moves the chat to a fresh key while the seat keeps the name OpenRig gave it.
- A short section in the preset reference.

A hermetic test runs the launcher against a stand-in openclaw (no OpenClaw, no model). No real OpenClaw has run. Cost: docs +67, tests +49; no product code.
2026-10-23 11:42:20 -07:00
Mike Schwarz ccbe27aaf6 fix(daemon): rig down gives back a guidance file's exact bytes (#1867)
rig down gives back a guidance file's exact bytes. After the seat-skills line (or any OpenRig managed block) has been added and down removes it, the person's CLAUDE.md or AGENTS.md is byte for byte what it was; a file the writer created is deleted when nothing else is left. Before, down collapsed blank lines, trimmed and added a final newline, so only a file ending in exactly one newline came back the same.

removeManagedBlocksFromFile now takes OpenRig's blocks out exactly as mergeManagedBlock put them in: each block, the "\n\n" written before the first block (or nothing when that block started a new file), and the newlines between and after blocks, including the one a re-merging start adds. Text a person adds is kept, including the bytes around a block kept because someone edited it. Blank lines a person adds right next to a block go with it, since they can't be told apart from the writer's. A file that existed before keeps its bytes, even if empty. Any other shape (a legacy RIGGED block, text against a block without the separator) falls back to the old cleaning. Blocks are found with stripManagedBlocks' own pattern, so this never removes more than cleanup did.

Cost: source about +34/-4 in managed-blocks.ts; tests in rig-teardown.test.ts; no new refusal or error code.
2026-10-23 11:21:01 -07:00
Mike Schwarz 48ea010b4c feat(daemon): a community harness can take its conversation's name from OpenRig (Hermes example) (#1783)
A harness that takes a conversation name from its caller can run as an OpenRig community harness, with OpenRig naming each seat's conversation and resuming it by that name, and a gone conversation is never replaced.

- names_conversation: true (a new optional preset field): on a new launch OpenRig starts the command with OPENRIG_CONVERSATION=openrig-<seat>-<8 hex>; on a resume it passes the saved name as OPENRIG_RESUME_REF. The name is recorded with provenance passed, and the seat shows it as named by OpenRig, not observed.
- Preset validation: names_conversation and launch_result can't both be true, and resume needs one of them (a load-time check on the preset, not a launch refusal).
- Readiness is process-only for a named seat, and the saved name stays unverified until something observes it. A resume never asks the harness to create a conversation; a gone one ends with the harness's own error and OpenRig starts nothing new.
- A Hermes example (docs/reference/community-harness-examples/hermes/): a preset declaring names_conversation and a small launcher with a Hermes home per seat. The model, provider and agent.max_turns go in each seat's config.yaml, not in flags, since Hermes refuses differing creation flags on a named session.

Native, on ovh03 at 5f97e871: the launch reached Hermes's prompt with its settings from the seat's config.yaml; the recall checks were not run. Cost: source +63/-23 in 5 files, tests +115/-5, docs about +90; one new field and one provenance value; no migration and no Hermes-specific daemon code.
2026-10-23 10:49:55 -07:00
Mike Schwarz a6ff0853dd fix(cli): host-binding writers wait out a slow holder instead of writing over it (#1862)
Host-binding writers wait out a slow lock holder instead of writing over it. recordHostObservation writes under an mkdir lock; a waiter used to busy-wait 40 x 5 ms (about 200 ms) and then write unguarded, so on a loaded machine its write could land between the holder's read and its rename, and one learned binding or a recorded host-id conflict was lost. That's how CI's host-bindings contention test failed once (w1: expected undefined to be 'host-1111').

- Waiters sleep (Atomics.wait) instead of spinning, and wait 3 s, past the existing 2 s stale takeover, so a slow holder is waited out or taken over as stale, not written over.
- A non-EEXIST mkdir failure (an unwritable folder) fails open at once, as before, without the longer wait.
- Unchanged: the lock, the stale takeover, the file format and the uncontended path. The only writer is rig host doctor; the daemon only reads the file.

A new deterministic test has a child hold the lock 600 ms and then write its stale copy; it fails on the old writer and passes on this one. Cost: source +14/-9, test +28/-1; no new refusal or error code.
2026-10-23 10:35:46 -07:00
Mike Schwarz bed0ccd090 feat(cli): rig mcp serve --http, with authenticated callers each acting as their own seat within their grant (#1860)
rig mcp serve --http serves the same MCP tools over Streamable HTTP, so an assistant that can't start a local process (a cloud assistant reaching a machine through a private tunnel, or a client on another host) can hand work to the team.

Who the caller is:
- No token file (default): the server's OPENRIG_SESSION_NAME, declared, not authenticated; loopback only, with the SDK's Host check against DNS rebinding.
- A token file: the seat of the grant whose bearer token the request carries, for that grant's rigs, roots and tools. Every request needs Authorization: Bearer, including on loopback, since a tunnel lands there. A missing, invalid or revoked token is 401 unauthenticated and never falls back to the env's seat. Once auth has been seen, a missing or unreadable token file refuses every request (500, with a generic message) until the listener restarts with no file.
- Listening beyond loopback (--bind) needs a configured grant. rig_whoami reports caller: authenticated with its principal, or declared.

Grants: rig mcp token add <name> --seat <session> [--rig] [--root] [--workspace] [--owner] prints a token once and stores only its sha256 in $OPENRIG_HOME/mcp-http-tokens.json (mode 600, atomic, add and revoke serialized under a lock file); entries are validated, and a grant always carries its own non-blank seat. token list shows no tokens or hashes; token revoke takes effect on the next request, and records stay.

What a non-owner grant reaches: each tool in MCP_OPERATIONS carries an access class (work, workspace, owner). Work tools are scoped to the grant's rigs: reads see rows whose source or destination is in scope, a queue update needs the row's owner in scope, and capture dispatches the rig the scope check authorized. Owner tools are offered only to owner grants. Each HTTP request gets its own daemon client and server, so concurrent callers never share an identity; stdio and every CLI command are unchanged.

Cost: source about +500 across mcp-http.ts, mcp-server.ts, commands/mcp.ts and client.ts; tests in mcp-http-daemon.test.ts on the real daemon routes and listener; refusals as Root's disposition selected. Native external-client proof is owed.
2026-10-23 10:28:27 -07:00
Mike Schwarz 3bce7888ac fix(daemon): keep an edited seat-skills line at down, and don't write it through a link (#1859)
Four protections for the seat-skills line, the one line OpenRig can add to a seat's CLAUDE.md or AGENTS.md when a person opts in with rig setup --router-line yes:

- An edit to the line is kept. rig down leaves an edited block in place and names it ("Kept 1 edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:"), and a start leaves it as it is, reports the file as edited and warns seat_skills_line_edited. An unedited line is still removed by rig down, back to the file's original bytes. "Edited" is the exact bytes, ignoring only the writer's own framing (edge newlines, CRLF), so an indented line counts as an edit; down and start share that rule (managedBlockBodyMatches).
- A linked guidance file isn't written through: the start leaves the link and its target alone, reports linked with a seat_skills_line_linked warning, and starts the seat without the line.
- A resume honours the answer: rig up --existing puts the line back only with a yes, never through a link, and doesn't report the skipped line as a restore gap.
- Setup says what is actually written: "one line, plus OpenRig's markers so it can be removed cleanly", and the preview prints the exact text from the writer itself, with where it goes.

Other managed blocks behave as before; they still follow links, which many people use on purpose. Cost: source and docs about +179/-32, tests +198/-3; new outcomes linked and edited with their warnings, none refusing a launch.
2026-10-23 09:52:55 -07:00
Mike Schwarz 7fc3ed5339 docs: a reference Cursor launcher and preset for community harnesses (#1771)
A reference Cursor launcher and preset for OpenRig's community harnesses (docs/reference/community-harness-examples/cursor/): the launcher creates a chat with cursor-agent create-chat, reports its id through the launch record (OPENRIG_LAUNCH_RESULT), and reopens it on resume (OPENRIG_RESUME_REF). A recorded chat that no longer exists is reported as missing, so OpenRig starts nothing new instead of showing a blank resumed conversation. The presets reference links the example, and one test drives the launcher against a stand-in cursor-agent.

Cost: docs and example +153, one test; no daemon change.
2026-10-23 09:47:06 -07:00
icey eb49cc2e9e fix(daemon): resolve preflight profiles off the request thread (#1759)
Preflight profile resolution runs in one lazy worker per preflight, so missing-name skill scans and synchronous catalog inspection no longer hold the daemon's request thread (Refs #1653). The existing resolver is reused for each member with fresh filesystem reads, no scan cache and no new setting, and the worker ends when the preflight completes or throws.

If worker startup fails because the daemon's working directory was deleted (uv_cwd), resolution falls back to the existing synchronous implementation, which is the behaviour before this change, in that case only; other errors still propagate. Instantiation's launch-member loop and first-start retry still resolve synchronously and are left for a follow-up. There is no request cancellation or I/O timeout here.
2026-10-23 09:45:55 -07:00
Mike Schwarz 267bde02e4 fix(daemon): a seat's own role as an auto-hinted startup file goes to that seat, never into the shared file (#1854)
A seat's own role, written as the documented spec shape (an agent's guidance/role.md as a startup file with no delivery_hint), is now delivered to that seat. Before, pod-mates of the same agent overwrote each other's role in the shared CLAUDE.md or AGENTS.md, and the last launch won. #1840 closed the same collision for saved projections; this is the startup-file route to it.

- One predicate, isPerSeatRoleStartupFile(path, orientation) in managed-blocks.ts, beside isPerSeatRoleGuidance and sharing its path rule: a startup file is a seat's own role when it's marked orientation: role or sits at the per-seat guidance/role.md path.
- The shared auto resolver checks it first, so an auto role file is send_text wherever the hint is resolved: Claude, Codex, Pi and stub delivery, the instantiator, the startup orchestrator, the seat lifecycle and the bundle view. Restore uses it too.
- An explicit delivery_hint is kept as written; shared guidance at other paths still merges.
- Seats already launched keep the hint resolved at their launch in their saved startup context, so a restore, relaunch or handover of an existing seat keeps merging until the rig is brought down and up again from its spec (rig down, then rig up <spec file>). The agent-spec reference's auto row says so.

Cost: one predicate and an optional orientation argument through the resolver's callers; a case per runtime; no new refusal or error code.
2026-10-23 09:22:57 -07:00
Mike Schwarz 94dc5ede61 feat(daemon): a community harness can reopen a conversation its launcher reported (#1769)
A contributor's community harness can reopen a conversation its launcher reported, and a conversation that's gone, unconfirmed or different is never quietly replaced.

- A preset sets resume: true alongside launch_result: true. On a resume, OpenRig tells the launcher the conversation through OPENRIG_RESUME_REF, and the launcher reports what it did in the same attempt's record.
- Reopened: the record names that conversation, and it's recorded as before.
- Gone: the record says error "missing". The launcher starts nothing, and OpenRig reports missingConversation with no fresh fallback; a restore rolls back on it.
- No usable record, or a different conversation: the seat needs attention (attention_required), keeps the conversation it asked for shown as not confirmed, and nothing is restarted.
- Only this harness's own token (community:<name>) is passed to its launcher. A former runtime's id makes a restore stop and ask, in the preview and in apply, with the complete one-seat fresh-launch command; a seat with no saved conversation stops with the usual reason.
- Presets without resume behave as before: a restore stops and asks. resume without launch_result is refused. A resume whose record folder can't be made starts nothing.
- docs/reference/community-harness-presets.md gains the resume section.

Cost: source about +107/-24 in 9 files, tests +145/-4, docs +25/-7; no new refusal code, flag or migration. A real harness's handling of a missing id is a native question not yet run.
2026-10-23 09:16:52 -07:00
Mike Schwarz f9e02eec2b feat(cli): --host and --codex-home for the commands that move a Codex home (slice 18, part B) (#1849)
An agent managing a person's subscriptions across two machines can move a Codex home on the other machine from where it runs: check the home, switch its saved profile, restart its seats and verify them.

- --host <id> on rig auth status, rig auth list, rig auth switch and rig seat restart (with --plan and --verify) runs the same command on that registered machine over its SSH entry, through the existing cross-host executor, as this seat. The remote command's own stdout, stderr and exit status come back, so --json stays that command's JSON; a [via host=...] line goes to stderr. An HTTP-only entry is refused with the fix (name the machine's SSH entry, or run it there).
- --codex-home <dir> on rig auth status|list|switch names the home to act on, here or there; a remote switch requires it.
- A lost SSH connection during a remote switch or restart reads as remote-outcome-unknown with the check to run, never as failed, so a restart isn't repeated blindly. The origin carries this machine's persisted host id, as rig send --host does, and known remote results pass through verbatim.
- A remote restart's audit names the calling seat as its operator unless --operator does.
- The openrig-user skill's "Move the home" step uses --codex-home, --host and the SSH entry.

What crosses: only each command's argv (runtime, profile name, home path, seat, reason, operator) and the origin seat out, and that command's non-secret output back. No credential moves, and there is no generic remote runner.

Cost: source about +200 in three CLI files plus the skill step and its digest; tests in move-commands-host.test.ts; no new daemon route, dependency or migration.
2026-10-23 08:53:47 -07:00
Mike Schwarz ffd56fd63e fix(daemon): the managed-refresh log line says a copy was kept, not that something broke (#1857)
Carries 0.7.0's managed-refresh log line onto main: the plugin vendor's message now reads that it preserved the previous contents of a managed file at its backup before the managed refresh, exactly as 0.7.0 (99aaada6) prints it, with its test. Found by the desk's 0.7.0 canonical-line read: this was the one code change in 0.7.0's release commits that hadn't reached main. Cost: +1/-1 source, +1/-1 test.
2026-10-23 08:46:52 -07:00
Mike Schwarz 52da344249 docs: the capability map teaches 0.7.0, and the CHANGELOG marks 0.7.0 published (#1855)
The 0.7.0 release boundary's docs on main:
- the packaged capability map (onboarding-width/public-what-you-can-do.md) now carries 0.7.0's capability delta, so an agent reading it after install learns what it can do now, under the header marker capability-delta-v0.7.0;
- the CHANGELOG marks 0.7.0 published;
- the README's skill install (npx skills add ... /tree/v0.7.0/skills/rigs) and install script links (raw .../v0.7.0/scripts/install.sh) point at v0.7.0 instead of v0.6.12.

Fact-only: each line matches the published v0.7.0. Cost: +74/-10 docs, no code.
2026-10-23 08:40:24 -07:00
Mike Schwarz 8725afef0b chore(release): advance development main to 0.7.0 and carry its release text (#1858)
Advances development main to 0.7.0 now that 0.7.0 is published, and carries its release text, the same way #1558 did for 0.6.12.

- Version fields only, 0.6.12 to 0.7.0, in the root, cli, daemon and ui package.json and package-lock.json; no dependency or script change.
- docs/reference/whats-new.md is byte-equal to v0.7.0's (tag v0.7.0, c99450f3).
- The install pins in getting-started.md and quickstart.md name v0.7.0.

This keeps main truthful about the published release while it continues toward 0.7.1; the release branch itself is unchanged.
2026-10-23 08:31:25 -07:00
Mike Schwarz 8a51419662 feat(daemon): a community harness's launcher can report the conversation each launch created (#1768)
A contributor's community harness can tell OpenRig which conversation each launch created, and the seat shows it honestly: reported by its launcher, not observed. This is the first step for harnesses that make their own conversation id before they start; Cursor's create-chat is the first target.

- The opt-in: a format-1 preset may set launch_result: true. OpenRig then starts its command with OPENRIG_LAUNCH_RESULT (one new file per launch attempt, under $OPENRIG_HOME/state/launch-results/) and OPENRIG_LAUNCH_ATTEMPT, reads that attempt's record once (up to 20 s), and records nativeRef on the session with provenance launcher.
- A bad record never stops the launch: missing, malformed, from another attempt or harness, reached through a link, or over 64 KiB, the seat starts with its identity unavailable and the reason named.
- A report isn't a verification: the restore preview and crash-cart don't read the seat as resumable. A launch-only harness still stops and asks at restore, the preview forecasts that decision, and its reason names the complete one-seat fresh-launch command.
- Presets that don't opt in work exactly as before. docs/reference/community-harness-presets.md documents the preset format and the record.

Not in this change: resume of a reported conversation, a reference launcher, and native evidence with Cursor.

Cost: source about +171/-18 across 10 files, tests about +110/-4, docs +70/-1; no migration, no new dependency, no new refusal, and no code runs from a preset.
2026-10-23 08:28:27 -07:00
Mike Schwarz f36806965b fix(daemon): keep the claim hint out of a waiting native prompt (#1852)
Adopting an existing session into a rig no longer types its identity hint into a seat waiting at a native approval prompt. At the default interruption level (off), the hint used to be pasted and Enter pressed even at a prompt such as Codex's "Press enter to confirm", and that Enter could accept whichever option was highlighted.

The claim hint now goes through SessionTransport's guarded send as an OpenRig notice (claim@system), the path the startup retry already uses:
- the seat's interruption level decides whether it is typed or held; a hint held by the level stays readable with rig seat held-messages, as one entry per occupant;
- at every level, a seat showing positive evidence of a native prompt gets nothing typed;
- a seat whose level holds OpenRig notices, including an unconfigured terminal member, holds the hint on a real claim too, so it no longer reaches a terminal's shell as a command line;
- the claim itself still succeeds, and a refusal is logged.

Two behaviours now match other notices: a Codex seat whose foreground is a shell OpenRig can't verify refuses the hint (target_runtime_unverified), and a pane in copy mode is taken out of it before the paste. No guard or transport code changed.

Cost: +26/-12 source in claim-service.ts; +115 test lines (a new claim-hint-prompt-guard test, four mock additions); no new refusal or error code.
2026-10-23 08:25:59 -07:00
Mike Schwarz 442223963f feat(provider): subscription move reports the Fleet app and the managing agent can read (slice 18, part A) (#1847)
A Fleet agent that moves a Codex home to another subscription can file one report that it, after its own restart, and the Fleet app can read back.

- rig provider moves report / POST /api/provider/moves files the report as a queue item to whoever asked, tagged subscription-move: host, home, from, to, reason, policy version, readings (observed, expected with its reset basis, or unavailable), each seat's result with its evidence, and peers.
- rig provider moves / GET /api/provider/moves reads them back, newest first, with followedBy, unreadable rows and an honest bounded window.
- Checks before anything is stored: a moved seat needs matched account evidence and an observed tool turn; unconfirmed or unknown needs what's missing, and that honest form always passes; recognized credential formats are refused anywhere in the input, keys included, and never echoed. A refusal records nothing and says the move is unchanged. Nothing here switches or restarts.
- openrig-user gains the account-policy lifecycle, observed versus expected room, unknown sign-in, balancing, and the report command.

This is slice 18's first increment, part A. The Fleet page and the two-host native journey are still to come.
2026-10-23 08:23:51 -07:00
Mike Schwarz ebfc0fb516 feat(seat): add terminal interaction presets and managed action checks (#1846)
A person can choose how far OpenRig and peer agents may act on a seat. `rig seat set-terminal-policy` saves one of three presets (no peer interventions, help within my instructions, full control), named helpers, and explicit self, OpenRig and peer exceptions for managed compaction and saved model and effort changes. `rig seat status` shows the planned rules, the attribution and what is actually enforced.

Existing seats keep their behaviour (migration 128 backfills full control), and a rig default is copied only to seats added afterwards. Checks cover managed Claude compaction and the saved model and effort setters, including just before compaction input; an already-owed restore or audit continues, and an uncertain paste stays uncertain. A selected denial returns terminal_policy_refused. An unreadable policy keeps OpenRig's own automatic compaction running while the seat's account reads unknown. Other interventions are guidance only, and attribution is cooperative: unattributed callers, dangerous sends, interruption levels and native permissions are unchanged.

This is the first increment of the terminal policy plan, with PM's three conservative defaults. Cost: two policy tables, a node-insert trigger and a setter.
2026-10-23 08:09:36 -07:00
Mike Schwarz 9711497ee4 feat(cli): MCP reads a result's artifact, decides inside a person's decision room, and links a revision (#1853)
Three read-and-decide steps for an assistant using OpenRig through MCP (slice 07, I2b):

- Read a finished item's artifact: its evidenceRef is read through the daemon's existing files routes when it falls under one of the daemon's shared roots, matched as written rather than resolved on the MCP machine, with a bounded output. Otherwise it reads unavailable, never a local path reported as delivered.
- See a pending human question and record a delegated decision through the existing route, inside the person's active decision room, as the agent that asked, beside their answers and never as one; it's refused when there's no room.
- Follow a revision link back through the existing chain of record.

No new daemon surface: the tools sit on existing routes. Output is bounded; the daemon's reader still loads the whole file.
2026-10-23 08:05:40 -07:00
Mike Schwarz cc1e07a3cd fix(bundle): forward install target into service plans (#1848)
After opting in to service bundles, `rig bundle install <bundle> --plan --target <dir>` previews the services the install will run, instead of saying archives can't run services and pointing to a source folder. The plan branch of the bundle install route now forwards targetRoot, as the apply branch already did.

One route-level test: a target-bound plan of a service bundle returns `planned` with its services_plan stage for that target, creates nothing and launches nothing. Cost: source +1/-1, test +20.
2026-10-23 07:52:20 -07:00
Mike Schwarz b38218faa7 fix(daemon): a relaunched rig's Slack channel reaches its lead when an archived generation shares its name (#1850)
## What a person sees

A rig is relaunched under the same name, and the relaunch archives the earlier generation. After that, a person's top-level message in the rig's mapped Slack channel doesn't reach the rig's lead. It goes to the connector's inbound destination instead, as if the rig had no representative.

## Why

`resolveRigRepresentative` (`queue-owner.ts`) counted every `rigs` row with the name, archived ones included. With the archived generation and the new rig, it saw two rows and returned null. That's its rule for an ambiguous name. Two callers use it:

- **Slack:** the inbound channel destination (`slack-subsystem.ts`) falls back to `inboundDestination`.
- **Edge ops:** `rig edge add` and `rig edge remove` (`rig-edge-ops.ts`) stop reporting representative changes in `routing`.

## The change

The name now matches active rigs only, the way the CLI's rig handles do (`rig-handle.ts`). An archived generation no longer makes a relaunched rig's name ambiguous.

- **Two active rigs with one name** still resolve to null, as before.
- **A name that only archived rigs have** now resolves to null too, and the message goes to the inbound destination. This is deliberate: a rig name resolves among unarchived rigs, as the CLI's handles do. Archiving isn't a lifecycle state, and a running rig can be force-archived, so an archived rig stays reachable by its id, by a channel's explicit `inbound` seat and by its threads. Only a top-level message routed by the rig's name falls back.
- **`resolveRigRepresentativeById`** is unchanged.

## Tests

- `slack-shared-foundation.test.ts`: with an archived earlier generation of `workshop`, the resolver returns the new rig's lead. The Slack subsystem's own composition (`resolveInboundChannelDestination` with the resolver) routes the channel to that lead. With only archived rigs, it's null.
- `rig-edge-route.test.ts`: with an archived rig of the same name, an add that gives the rig a second delegation root still reports `representative: { from: lead, to: null }`.

Both cases fail before the change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-23 07:37:21 -07:00
Mike Schwarz 5ac0640859 fix(cli): ask before stopping the caller's own rig (#1838)
`rig down` warns and asks `[y/N]` when the caller's resolved seat belongs to the target rig. Without a TTY it exits 1 without stopping and gives the exact `--yes` command; JSON carries the same consequence and action. `--force` still controls immediate killing independently of confirmation. OpenRig does not stop externally attached sessions.

Membership uses the existing `whoami` resolver and compares rig IDs. With `--host`, a name-only caller remains unresolved because another instance may have the same session name; a node-ID match still asks. Unresolved callers retain existing teardown behavior. The cost is one additional identity read (2s timeout) when a usable identity exists, and a confirmation step for resolved own-rig shutdowns. There is no daemon teardown change.

Validation:

- Round 2: remote-name collision reproduced before the fix; 124 shutdown/identity/remote-host tests pass afterwards, plus CLI typecheck and `git diff --check`.
- Prior head: complete CLI suite passed (5,423 tests, 3 skipped), workspace build and lint passed. Actual readline PTY check: yes sent one fixture teardown; no and EOF sent none and exited 1.

No live rig stopped. Full final-head required checks and installed-scenario coverage remain with CI; the actual interactive prompt was checked through the PTY fixture.
2026-10-23 07:24:14 -07:00
Mike Schwarz 0b4e475023 feat(permissions): a planned per-seat permission account, and setup's router-line question (#1843)
Before anything changes, a person can see each seat's planned permissions and choose whether OpenRig adds its one router line to shared guidance files.

- rig seat status shows the native permission argument a seat's next fresh managed launch will pass, where it comes from (a seat choice, a member or rig policy, the kernel default, the team default, or the floor), what OpenRig adds (team or kernel lists, Codex writable roots, non-interruptive flags) and what isn't known (native settings OpenRig doesn't manage, whether the harness applies it, Codex's network default). A seat with no policy shows the floor it actually launches with; a Codex rebuild that carries its predecessor's permissions is named as conditional. It's labelled planned, not observed.
- rig setup previews the router line, the files it goes in and what a no gives up, and saves the answer with --router-line yes|no; a saved no stops only that line, and the environment setting still takes precedence.

This is a partial step: it covers existing seats, not a pre-launch view of a new team, and it doesn't move OpenRig's other writes, which setup now lists accurately.
2026-10-23 07:05:41 -07:00
Mike Schwarz 713e73120f fix(daemon): a single send that landed marks its outbox audit row delivered (#1844)
After a successful single `rig send`, the sender's outbox audit row now reads delivered, with its time, instead of staying pending forever. The route already recorded the entry and dropped it; it now marks it delivered with the existing markDelivered, as broadcast does for its successful results. "Delivered" here means the input was dispatched, the same as broadcast.

Unchanged: retained, submit-only, anonymous and already-ledgered sends; a failed or refused send still writes no row; old pending rows aren't backfilled. The queue's recovery scans select only wake-intent rows, so these audit rows were never re-delivery candidates.

Refs #1813 (part of the outbox report; the reporter's event-loop stall and the stuck-sweep recovery are separate). Regression test by review-r2@ovh07, rebuilt beside the A3 control: it fails on main with pending and passes here. Cost: source +3/-1, test +16.
2026-10-23 06:52:40 -07:00
Mike Schwarz d0710f6b63 fix(bundle): bind example Vault port to loopback (#1845)
The secrets-manager example's Compose file publishes Vault's port on the host's loopback only (127.0.0.1:8200:8200 instead of 8200:8200). Someone who opts in to service bundles and installs it on a box with a public address no longer exposes a Vault dev server, with its fixed dev root token, to the network. The built-in rig up secrets-manager team uses the same file.

Nothing that reads Vault changes: the specialist's context and skill, wait_for and the surfaces already use 127.0.0.1:8200 on the host, and the container still listens on 0.0.0.0 internally for its own healthcheck. Cost: +1/-1.
2026-10-23 06:49:48 -07:00
Mike Schwarz e33d722aac fix(daemon): a seat's legacy role guidance never merges into shared guidance (#1840)
A seat saved from an older spec no longer writes its own role into the shared CLAUDE.md or AGENTS.md, where the last seat to write it overwrote every other seat's role. Restore no longer brings that block back from old snapshots.

One helper, isPerSeatRoleGuidance, replaces the five rig-role checks in the Claude, Codex, Pi and stub adapters and in restore: it matches rig-role, or a role block whose source is a seat's own guidance/role.md. Shared guidance from any other path still merges. Each skip adds a warning to the projection result, because a seat whose only role delivery was that merge now gets its role by send_text only. A shared guidance resource with the id role at a guidance/role.md path is now treated as a seat's own role; give it another id or file name to keep it shared.

Cost: source about +40/-23 across the adapters, the helper and restore; tests for the adapters, restore and the historical collision, which now asserts that neither seat's role merges.
2026-10-23 06:49:19 -07:00
Mike Schwarz 19b921b827 feat: MCP follow-through and orientation (handoff successors, finished-work search, team and lead, context reads) (#1842)
An assistant connected through MCP can now delegate usefully and come back for the result: rig_team names a rig's lead (its one delegates_to root, resolved by id) and each seat's role, runtime, delegation and activity; rig_queue_show follows a handed-off item to its successor; rig_queue_list takes since for finished work; rig_slices, rig_mission, rig_context_pack and rig_context_get read the work context without returning host paths.

The daemon's queue list gains handedOffFrom and updatedSince on both list paths (one shared helper, one new 400 code updated_since_invalid for the new parameter). rig ps's seat-activity formatter moves to a shared module unchanged. An older daemon that ignores the filters is reported as filter_unsupported rather than returning a list that only looks filtered.

Cost: source +312/-36, tests +205/-6; no dependency or migration.
2026-10-23 06:23:41 -07:00
Mike Schwarz c29396422f test: cover installed MCP stdio identity and queue results (#1841)
Extends the existing installed daemon-restart scenario with the MCP server actually running over stdio from the installed package, as an assistant would use it: rig_whoami with and without a configured seat, rig_queue_create and rig_queue_show with the seat's attribution, structured success and error results including the daemon's own 404 code, and the server's instructions. CI now notices when the MCP surface an assistant depends on breaks in the installed package.

Test-only: five files, net +112, in packages/test-system/ci and the scenario admission tests. No product source, dependency, model, container or scenario-grammar change.
2026-10-23 06:22:35 -07:00
Mike Schwarz b8a4843a5a fix(cli): rig up --plan doesn't start a daemon or its kernel (#1836)
On a fresh machine, rig up --plan auto-started the daemon to compute the plan, and starting the daemon booted the kernel's agents, which could spend model turns before the plan reported anything. A preview shouldn't start agents.

Now, when the daemon is actually stopped or stale, --plan stops before starting anything and says how to proceed: rig daemon start (with the kernel), rig daemon start --no-kernel (no agents), or rig up without --plan. With --json it answers { ok: false, code: "daemon_not_running", plan: true, changed: false }. A blocked or unconfirmed probe keeps its existing guidance and cause instead of claiming nothing is running. Plain rig up still auto-starts, and a running daemon still answers the plan.

New behaviour: --plan with no daemon exits 1 (daemon_not_running) instead of starting one.
2026-10-23 06:04:01 -07:00
Mike Schwarz 0e2f9a6b9f docs: read identity from rig whoami, and keep the activity token out of output (#1839)
Agents sometimes printed the activity-hook token while looking for a seat's identity. This tells them where to look instead.

- rig attach --json's help says its output includes the activity env the attached app needs, that the token is a credential, and to use rig whoami for identity.
- The openrig-user skill's self-attach notes and both copies of the rigs skill say to use rig whoami or rig seat status for identity or state, not the attach JSON or a file holding a seat's environment, and not to print OPENRIG_ACTIVITY_* values.

Text only (+7/-3), plus the regenerated skill digest. rig attach's human output and --print-env never carried the token and are unchanged. The plugin skill text reaches existing installs when the plugin version next advances.
2026-10-23 05:57:26 -07:00
Mike Schwarz ee5187e2ca fix(restore): leave a seat joined with rig attach as an external CLI attached (#1834)
After rig down, its own next step, rig up <rig> --existing, read a seat joined with rig attach as an external CLI (for example Claude Desktop) as stale: it was marked detached, then stopped at awaiting-decision or replaced by a tmux occupant. A launch naming that seat replaced it too.

Now a full restore and a targeted launch leave such a seat attached and report it as left_attached ("OpenRig can't see whether that app is still running"); a tmux seat beside it restores as before. The restore forecast (rig up --existing --plan, rig status, the recovery view) says leave-attached with the same reason, and rig start prints the reason. rig down's line now reads "Not stopped: … OpenRig doesn't stop or check" an externally attached session.

left_attached is a new per-seat result value and, for a single-seat rig launch on an attached seat, a new HTTP 409 code; to replace the attachment deliberately, use rig unclaim <name>, then launch.
2026-10-23 05:40:00 -07:00
Mike Schwarz ca19f86477 feat(cli): MCP restore status and whoami, structured results, one error shape, server instructions (#1835)
The first increment of a maintained MCP surface for outside assistants (Claude Desktop, ChatGPT and others).

- rig_whoami: the seat this server acts as, from the configured OPENRIG_SESSION_NAME (not a verified caller). With no seat set it says so and calls nothing.
- rig_restore_status: follows a restore's attemptId to its receipt. An attempt with no completion yet, still running or stopped before it finished, answers attempt_incomplete; it isn't a receipt. rig_restore now says it only starts a restore.
- Structured results (structuredContent) for whoami, restore status and the queue tools, beside the unchanged text.
- One error shape, { ok: false, code, message, status? }, on errors the tools return. The code is the daemon's own (from code, from error beside message, or a code-only error), else http_<status>. Existing error text is byte-identical. The SDK's own argument validation stays text-only.
- Four lines of server instructions, and a short operation table beside the registrations with a test that every tool's routes are mounted and its hints match its effect.

Tool names, arguments and stdio are unchanged. No daemon change. Cost: +172/-49 source in one file, +214/-8 tests.
2026-10-23 05:20:19 -07:00
Mike Schwarz 64d7ddf628 docs: connect OpenRig seats with A2A agents (#1829)
Adds docs/reference/a2a-agents.md: how an OpenRig seat hands a queue task to an outside A2A agent and gets the result back on the row, and how an outside A2A client reaches a seat and gets its result as an artifact. It composes the official A2A Python SDK 1.1.0 with existing queue commands; there's no product-code change.

It covers task identity, result ownership, input requests, cancellation and uncertain writes; keeps quiet streaming waits open, with how to stop one locally; names the tested SDK 1.1.0 stack and the observed incompatibility with Pydantic AI 2.60.0 / FastA2A 2.1.1. Pipe trials on SDK 1.1.0 exercised both directions. Whether a cold agent can follow the page unaided is still untested.

Text only: +343/-0.
2026-10-23 05:11:54 -07:00
Mike Schwarz 0961af69e7 fix(cli): MCP server sends the terminal token, catches up four tools, declares read/write hints (#1831)
Brings OpenRig's MCP server, the way Claude Desktop and other assistants reach a team, up to date with the core.

- rig_send and rig_capture send the terminal bearer token when the daemon requires one, so they no longer get 401. The token goes only on those two transport calls.
- rig_up takes cwd (absolute, since the server has no caller directory), env (one rig spec or bundle; the same rule as rig up --env), rigName and fresh, and its description says what the route accepts.
- rig_chatroom_send resolves an id or a name like the other rig tools and drops the unused sender; rig_chatroom_watch says it returns recent history and takes topic, sender and limit. Both report an error instead of an empty room when the rig list can't be read.
- rig_queue_update takes resolution and reopen.
- Every tool carries MCP annotations: 12 read-only, 4 additive, 8 destructive, so a client can grant standing consent to reads.

Tool names are unchanged. Cost: +100/-51 source in one file, +124/-3 tests. New input checks: an invalid env entry and a relative cwd on rig_up's new arguments.
2026-10-23 04:20:40 -07:00
Mike Schwarz dee5c698bf fix(queue): keep a row's evidence_ref when it's closed as done (#1830)
A seat finishing a row could only put its artifact link in the resolution text: evidence_ref was refused on every update except a park on a human seat.

Now `rig queue update --state done --evidence-ref <path>` stores the pointer on the closed row through the existing persist path, and `rig queue show`, `GET /api/queue/:id` and the MCP server return it. Cancelled and handed-off closes still refuse it (a handoff already puts the pointer on its new row), and every other non-park update refuses it before changing anything, with the same error code. A park on a human seat behaves as before. Slack's queue projection carries the field; this doesn't add completion posts.

Cost: +15/-7 source in 3 files, +27 tests. One refusal removed; no new refusal or error code.
2026-10-23 04:13:44 -07:00
Mike Schwarz f086a7ce85 docs(changelog): add 0.6.13, 0.6.14 and the unreleased 0.7.0 (#1833)
The root CHANGELOG on main stopped at 0.6.12; the 0.6.13 and 0.6.14 entries existed only on their release tags. This adds them exactly as they appear on v0.6.13 and v0.6.14, and adds 0.7.0 from its release notes, marked unreleased until it's published.

Text only: CHANGELOG.md, +495/-0.
2026-10-23 04:12:46 -07:00
Mike Schwarz 9848c7b681 test: catch lost plugin edits and stale packaged release guidance (#1832)
Adds two checks for upgrade problems people have hit.

1. The installed daemon-restart scenario now seeds an older plugin and an edited binary. When the backup is denied, the restart must skip the managed refresh and leave every target unchanged, with a specific warning. Once permissions are restored, it must keep the exact recovery bytes and move to the shipped version. A missing or corrupt proof fails the report.
2. The package-content test reads the same tarball's what's-new heading and intro against its package.json, with independent stale-heading and stale-intro controls.

Test-only: 5 files, +83. No product, scenario-grammar, scheduler or dependency change. One existing installed case gains one extra daemon start and file hashing.
2026-10-23 04:12:00 -07:00
Mike Schwarz dae1dd99fc docs(reference): operation correlation matches exact request text, and refuses an invalid ID (#1828)
docs/reference/operation-correlation.md now states two facts from the code: replay matching compares the exact request text, so a change in whitespace or key order is a different request, and an operation ID that isn't a UUID gets HTTP 400 before the route does anything else. A different request reusing an ID gets 409 only once it passes the route's own validation.

Cost: +1/-1, one paragraph. Text only.
2026-10-23 03:28:16 -07:00
Mike Schwarz 4c2862ecfa fix(daemon): don't record a stop for a seat joined with rig attach as an external CLI (#1827)
A seat joined with `rig attach` as an external CLI (for example Claude Desktop) has no tmux session. `rig down` read that as a stop and recorded it, and a subset launch marked the seat detached and held, while the app kept working.

Now both loops skip a seat whose binding is an external CLI, the way the historical stop path already did. `rig down` lists those seats in a new optional `leftRunning` result field and prints one line about them; they keep their skills and guidance. `rig down --delete` still deletes the rig. No new refusal or error code.

Not changed here: a later full restore (`rig up --existing`) still treats such a seat as stale; that follows separately. `rig remove` and `rig shrink` use a different path.

Cost: +23/-2 source in 3 files, +35 test lines in 2 files.
2026-10-23 03:24:40 -07:00
Brad Vincent 69dad5d417 fix(codex): bound project config discovery to a known root (#1825)
After #1791, a Codex seat could still keep generated writable roots because OpenRig read a writable config above Codex's actual project root. Codex ignores that file, stays read-only, and can then reject the resulting --add-dir arguments.

Now the visible project-config scan stops at the root Codex itself discovers. Above-root and malformed configs no longer count. Where the boundary is uncertain (Git markers it can't read, custom or managed markers), existing roots are kept, as before. No new refusal or error code.

Thanks to @bradvin for the follow-up and for merging main to keep #1824's plugin wiring intact.

Cost: +38/-8 production lines (code +25/-4, comments +13/-4), +146/-4 test lines.
2026-10-23 03:07:04 -07:00
Brad Vincent 2fc23521d2 feat: correlate create/import/add-member requests with created IDs (#1790)
A client that loses a create, import or add-member response after the daemon has already created resources had no way to tell which resources came from its own request; retrying with a fresh request could duplicate work.

Now a client can send a UUID in X-OpenRig-Operation-Id (advertised as operation-correlation.v1 at GET /api/operations/capabilities). The daemon records the rig and node IDs that request creates inside the existing transactions. Replaying the exact same request returns those IDs and their current database state without executing again; the same ID with a different request returns 409. Requests without the header behave exactly as before. Migration 127 adds one table. docs/reference/operation-correlation.md describes the contract.

Thanks to @bradvin for the change and for reshaping it to the smallest contract.

Cost: +179/-46 production lines, plus tests. The 409 applies only to clients that opt in.
2026-10-23 02:41:00 -07:00
Mike Schwarz 69842a09c0 feat(cli): rig acp run hands a queue row's task to an ACP agent (#1823)
An OpenRig seat had no supported way to hand work to an ACP agent such as Hermes or OpenClaw and get an honest result back; the trials used one-off glue.

Now `rig acp run <row>` starts the agent, sends the row's task as the prompt, posts progress and the outcome as notes on that same row, and leaves closing it to the row's owner. A permission request becomes a row to the owning seat; the answer, given on that row, goes back to the one pending request exactly once, and a cancelled turn's answer is never sent. `rig acp cancel` records requested, acknowledged and upstream spend separately, with spend unknown unless measured. A second Ctrl-C detaches without waiting. Stop reports settled, incomplete or unknown for the processes the run launched, and never kills anything. An empty end_turn with no usage, an OpenClaw interruption chunk, or a transport closed mid-turn reads unknown, not done. docs/reference/acp-agents.md explains how to use it.

Cost: new commands and docs only, with no daemon change or new dependency (a small hand-written JSON-RPC client). No new refusal.
2026-10-23 02:28:23 -07:00
Mike Schwarz 8e1fae0356 fix(codex): trust shipped core plugin hooks on first install (#1824)
A fresh Codex kernel or team seat either lost OpenRig's own plugin (refocus and skills) or stopped at Codex's review of its six hooks.

Now OpenRig installs its plugin with Codex's own commands at daemon start and on a fresh launch for a selecting seat, and trusts only the plugin's six shipped hook definitions, by exact hash, on a first install (no plugin cache and no config layer already listing it), after checking the hook files' bytes against this daemon's copy. A person's existing disables, hook choices and untrusted installs are kept, and stay under Codex's own review. The four inline hooks remain the only activity reporters; the plugin adds refocus and skills.

The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.

Cost: +450/-97 in 12 files. No new refusal.
2026-10-23 02:12:55 -07:00
Mike Schwarz 0c4164cbd1 docs(operator): name kernel seats that wait, and keep the person's prompt choices (#1818)
A kernel seat waiting at a prompt, such as Claude's usage-limit menu, wasn't named to the person, and the operator's guidance didn't say that launch grants, team defaults or another seat's answer aren't the person's answer to a trust, hook-review or permission prompt.

Now the operator tells the person which seat is waiting, when it resets and its choices, and keeps the choices the person made during install. It answers such a prompt only when an operation the person asked for names it, or when they told it outright to decide that kind of prompt (step 10).

Cost: text only, +24/-5 (3 files). No new refusal.
2026-10-23 01:38:30 -07:00
Mike Schwarz 49abbc5d3e fix(proof): compare canonical project roots (#1814)
When a project root was reached through a symlink or a path with a dot segment, proof reads, judgments and project scopes refused it as a different project (409 project_changed), even though it was the same folder.

Now both readers compare the real paths, so the same folder matches however it's spelled. A link that points at a different project is still refused with 409 project_changed, and a pinned root that was removed still answers 409 project_unavailable with its reason.

Cost: source +8/-3 (2 files), tests +36 (2 files). No new refusal or dependency.
2026-10-23 01:03:01 -07:00
Mike Schwarz 776776eaea fix(daemon): preserve managed files before startup refresh (#1821)
Upgrading OpenRig could silently discard someone's edits to a managed plugin or global skill file: the daemon's startup refresh overwrote the file with the new version and kept no copy.

Now, before a version-advancing refresh writes over a file that differs, the old bytes are copied exactly to a fresh directory under $OPENRIG_HOME/backups/managed-refresh/, and startup logs where. A retry never overwrites an earlier copy. If a copy fails, that plugin or skill is left exactly as it was, with a warning, and startup carries on. Equal or newer installed versions and unversioned external skills are untouched. The upgrade guide now says startup may already refresh files and that a later plan doesn't protect them.

Recovery keeps the old bytes; the custom behaviour may no longer be active after the refresh.

Cost: source +29/-6, tests +88/-0, upgrade skill and its mirror +22/-10, digest +2/-2 (7 files). Backups accumulate without cleanup. No new refusal.
2026-10-23 00:32:41 -07:00
Mike Schwarz 92f7730703 fix(daemon): preserve seat database and tmux directory (#1812)
Seats launched by a daemon that was started with a chosen database or a custom tmux directory could pick up a different OPENRIG_DB or TMUX_TMPDIR from the shell, so their rig commands talked to the wrong state.

Now the daemon passes the database it actually selected (resolved to an absolute path, with :memory: kept as is) and its TMUX_TMPDIR, when set, into every launched seat, across scoped launch, its fallback and shell startup. The getting-started page says so.

Cost: source +4/-1 (2 files), tests +53/-11 (4 files), docs +4/-2. No new refusal or dependency.
2026-10-23 00:23:57 -07:00
Mike Schwarz 200e05d39d fix(daemon): exclude generated plugin files from aliased cwd (#1819)
When a seat's working directory was reached through a symlink (for example macOS's /tmp or /var), OpenRig's generated core-plugin files under .codex/plugins were reported as untracked files to commit, because one comparison used the uncanonicalized cwd.

Now that comparison resolves the cwd the same way as the rest of the function, so those files are excluded quietly, and a second pass adds nothing.

Cost: source +1/-1, tests +13/-0 (2 files). No new refusal.
2026-10-23 00:06:29 -07:00
Mike Schwarz 80807d5049 fix(codex): avoid automatic plugin review on fresh starts (#1816)
A fresh Codex kernel or team seat stopped at Codex's native review of the core plugin's six hooks, because OpenRig had started installing that plugin automatically at boot and on fresh launch (#1551, after 0.6.14).

Now OpenRig no longer runs `codex plugin` at all. The boot installer, the fresh-launch install and their helpers and tests are removed. The four managed inline activity hooks and their trust, existing plugin configuration, user hook choices and normal native review stay as they were, and seat-selected skills still project separately.

Cost: source +6/-257, tests +40/-189, docs +9/-14 (7 files). Fresh Codex homes no longer get the plugin installed or upgraded automatically, or its refocus hooks; plugins already installed are untouched. No new refusal.
2026-10-23 00:01:29 -07:00
Mike Schwarz 66f04ab2ed test: canonicalize temporary roots and retry transcript cleanup (#1815)
Contributors running the daemon suite on a Mac saw 19 failures whenever TMPDIR was the default /tmp or /var symlink, because the tests compared paths without resolving it, and one transcript test threw ENOTEMPTY while cleaning up after its private tmux server.

Now the affected fixture roots are resolved with realpath, and that cleanup awaits a bounded async remove (5 retries, 20 ms). No product code changes, and no assertion is added, removed or edited; CI already used canonical paths, so it runs the same checks.

Cost: tests +11/-11 (5 files). No product change.
2026-10-22 23:41:31 -07:00
Mike Schwarz 0928f36337 fix(cli): disclose unconfirmed liveness in rig ps (#1810)
A seat whose tmux server was gone kept reading a bare `running` in `rig ps`, because an unavailable tmux isn't proof the session ended, and the evidence the daemon already held wasn't shown.

When a seat's last applicable identity observation found tmux unavailable, `rig ps` now explains that recorded running is unconfirmed and shows the observation time. Compact node JSON carries the existing stored status and concise identity verdict; both fields are selectable with `--fields`. No session is inferred dead or detached, and existing attention and stop states retain precedence.

Cost: CLI source +31/-4 (1 file), tests +44 (1 file). No new refusal, error code, daemon state or probe.
2026-10-22 22:32:31 -07:00
Nash Aschenbath d715c1013e feat(terminal): select Herdr sessions per request (#1076)
The daemon chose its Herdr session once, at startup, from its own environment, so someone running two rigs on one host couldn't keep their tiles apart: a per-command `HERDR_SOCKET_PATH` was ignored.

Now `rig terminal open <view> --provider herdr --session <name>` places a view in the named Herdr session on the daemon's host. Preview, status, desktop opening, workspace reuse and layout use the same selection, and each request has its own transport, so concurrent opens don't change another request's socket or the daemon's environment. Leaving out `--session` keeps the configured endpoint, and `--session default` selects it explicitly. A requested session that can't be used stops with an error instead of falling back to the default session.

Behaviour that changed, for direct HTTP requests: a rig-addressed open now checks the preview it carries, so an out-of-date preview stops the open and asks for a refresh, as the main terminal-open address already did. A `session` value is no longer ignored: an invalid name, a value that isn't text, or a provider that can't select a session (cmux) gets an error instead of opening in the old default place. The CLI also stops, rather than opening silently in the wrong place, when an older daemon can't confirm the selected session or when an in-Herdr window open would cross into another endpoint. When the caller's session is known, that error suggests the matching `--session`, named from the same socket the check used.

Limits: the selected session must already exist on the daemon's host. The tests use real sockets with synthetic Herdr peers; the Herdr desktop app wasn't exercised.

Thanks to @Aschenbath for the PR, and to @team-sudarshana for the issue and for checking the shape on their own host.

Cost: source +174/-69 (7 files), docs +105/-3 (4 files), tests +401/-6 (3 files). The two HTTP changes above were approved by the founder; two new error codes (`invalid_terminal_session`, `unsupported_terminal_session`); the rig-addressed open reuses the existing `preview_changed`.
2026-10-22 21:37:34 -07:00
Mike Schwarz 6582fe293d fix(cli): rig queue keeps human notices off stdout (#1808)
An agent that parsed the stdout of a default-flag `rig queue show` or `rig queue whoami` as JSON failed, because a human notice followed the JSON on stdout.

Now `show`'s bounded-preview line and compact empty-field notice, and `whoami`'s work-candidate list, go to stderr, as `rig queue transitions` and `queue create` already do. A terminal shows the same text in the same order, and the data behind each notice is already in the JSON (`bodyTruncated`, `currentWorkChoice`).

Limit: with `output.compact` on (off by default) and no `--full`, write commands still print compact mode's text receipt; `--json` gives the receipt as JSON. Refs #1806.

Thanks to @BillyOutlast for the report and its three surfaces.

Cost: source +9/-10 (1 file), tests +13/-9 (2 files). No new refusal or error code.
2026-10-22 21:07:58 -07:00
Mike Schwarz 193ca1024c refactor(daemon): share watchdog policy spec checks (#1807)
Watchdog updates kept a second copy of four policies' spec requirements. Reuse the evaluator-owned checks and the pool scanner's existing no-pools rule so those decisions have one source. Scanner error order and evaluator outcomes stay unchanged; full-spec reminder updates now accept whitespace as evaluation already does, while message-only edits still require text. Update errors retain their codes and use evaluator wording.

Cost: source +68/-63 (5 files), tests +22 (2 files). No new refusal or error code.
2026-10-22 21:02:26 -07:00
Nash Aschenbath 65e49b5e1e feat(daemon): add policies.claude_compaction.preparation_root for restore maps (#1585)
Managed Claude compaction wrote each restore map under the seat's launch folder whenever the daemon's own write check passed. A seat whose Claude permissions deny edits there couldn't write its map, so preparation ended `preparation_incomplete`, and the only way out was `--skip-map` or many generated permission rules.

Now an operator can set an absolute, instance-wide `policies.claude_compaction.preparation_root` (or its environment variable). Manual and automatic compaction then use `<root>/<session>/<attempt>/RESTORE-MAP.md`, chosen once per attempt and kept through preparation, publication and restore, even if the setting changes mid-attempt. The operator grants Claude edit access to that root; OpenRig doesn't change Claude's permissions. With the setting unset, paths are exactly as before, including the instance-home fallback.

A configured root that can't be created or written, or that already holds a different `.gitignore`, stops that attempt with `preparation_directory_unavailable`, shown in `rig compact --state`, instead of silently choosing another folder. `--skip-map` still works.

Limits: use a dedicated root. OpenRig writes a `.gitignore` of `*` into the root itself, so a root inside a repository folder without its own `.gitignore` hides new files there. No live Claude compaction or permission session was run; the tests use real temporary files with synthetic delivery.

Thanks to @Aschenbath for the PR, and to @aanestesia for the issue and for measuring what the workaround cost.

Cost: source +59/-21 (4 files), docs +43/-1 (2 files), tests +272/-8 (7 files). One new failure reason, `preparation_directory_unavailable`, reached only with the new setting.
2026-10-22 20:57:15 -07:00
Nash Aschenbath f9f843bf62 feat(daemon): record probe evidence on failed Claude launches and refused attention clears (#1499)
A Claude seat whose resume launch failed reported a fixed message, and a refused `clear-attention` returned only the pane's status and code, so nobody could tell which probe result or which login text the decision rested on.

Now `node.startup_failed` and `seat.attention_clear_refused` carry the probe's evidence: its code, which of the fixed login-refusal strings matched and where, the capture's line and byte counts, a SHA-256 of the text the probe read, and the pane size. No pane text or free-form probe detail is stored. Every probe-backed failed return carries it, and a missing strict reconciler records its own refusal without inventing an attempt or evidence. Optional pane geometry has a 250 ms deadline, so a slow or failing read leaves the size empty and the failure is still recorded. Recovery decisions and error messages are unchanged.

Limits: the deadline bounds only the wait; it doesn't cancel the tmux command. The geometry is read after the capture, so it isn't an atomic snapshot. An unexpected reconciler exception keeps its existing error path and isn't recorded as a refusal.

Thanks to @Aschenbath for the PR, and to @AdamWhitehurst for the issue and for checking the PR against his case.

Cost: source +118/-17 (8 files), docs +33 (1 file), tests +237/-10 (5 files). No new refusal or error code; new fields on two existing events.
2026-10-22 20:45:27 -07:00
Mike Schwarz 1c186e7640 fix(watchdog): a held send still counts after many updates in place (#1805)
#1803 merged with a stated limit: its record of replaced schedules kept the last 8 per job, so after nine or more `rig watchdog update` calls during one in-flight delivery, that delivery's receipt could be missed and the same wait notice sent again.

Now the schedule an in-flight delivery started under is kept until that delivery is accounted for, even past the 8-entry trim, and is released when the attempt is recorded. A real reschedule still clears it, so a re-park keeps the due point it set. Jobs that are never updated are unchanged.

Limit: if the transport rejects a send outright or accounting throws, one kept schedule per job can remain until the next delivery replaces it; no later misaccounting follows from it.

Refs #1803, by @AdamWhitehurst, whose `rig watchdog update` this completes.

Cost: source +25/-4 (2 files), tests +52 (1 file). No new refusal or error code.
2026-10-22 19:56:36 -07:00
Mike Schwarz 3162423eec fix: restore reasons name the one-seat command; clearer y-answer outcomes (#1804)
After a partial restore, the daemon's reason for a seat left awaiting a decision said "Re-run with --fresh <id>". That re-runs the whole-rig restore, which is refused while sibling seats are running, and it was the only instruction a `--json` reader saw. #1800 fixed the CLI's own printed line; this fixes the reason itself.

Now the eight awaiting-decision reasons name the one-seat command, `rig seat launch '<id>@<rig>' --fresh --reason "<why>"`, built by one shared helper that two existing messages also use, with their text unchanged. After a yes at the prompt, a seat that needs attention reads `needs attention --` rather than "not started", and `rig start --json` prints a refusal to stderr.

Limit: for an archived rig, the printed command finds no seat, as before.

Refs #1332.

Cost: source +28/-16 (5 files), tests +38/-5 (5 files). No new refusal or error code.
2026-10-22 19:20:34 -07:00
Adam Whitehurst 4c92b11440 feat(daemon): rig watchdog update changes a job's interval, spec or message in place (#1803)
Retuning a watchdog meant `rig watchdog stop` then `register`, which gave it a new job id and broke anything still holding the old one, such as a `queue block --wake-watchdog` attachment.

Now `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]` changes only the fields given and keeps the job's id, registration, history, queue attachments and every other field. It reuses the existing storage, validation and scheduling. A new interval takes effect from the job's last recorded evaluation, and the response says when it's next due. A queue wait's backoff is retuned with it, so a delivered notice isn't replayed. A delivery already in flight when the update lands still counts. The new verb refuses a stopped or finished job, a daemon-owned or daemon-timed policy, a change of policy or target, and edits that would have no effect; `register`, `stop` and existing jobs are unchanged.

Limit, with a follow-up: the record of replaced schedules keeps the last 8 per job. If nine or more distinct updates are accepted while one delivery is still in flight, that delivery's receipt can be missed, and the same wait notice can be sent once more.

Thanks to @AdamWhitehurst for the issue and the PR.

Cost: source +342/-10 (CLI +43; daemon +299/-10), docs +1, tests +499 (2 files). Seven new error codes, all on the new verb, and one new event, `watchdog.job_updated`.
2026-10-22 18:38:10 -07:00
Mike SchwarzandAlan Dampog 7224b36b0f fix(cli): print a one-seat command naming exactly the awaiting seat (#1800)
After `rig start` or `rig up --existing` left a seat awaiting a decision, OpenRig printed `rig up --existing <rig> --fresh <seat>`, which re-runs the whole-rig restore and is refused while sibling seats are running. Answering yes at the prompt did the same thing: `rig start` then advised `rig down`, and `rig up --existing` printed nothing while the seat never started.

Now both the printed line and a yes at the prompt use the one-seat route: `rig seat launch <logical id>@<rig> --fresh --reason "fresh-prime after restore"`. It starts exactly that seat, older dotted ids included (`dev.ops.impl` and `dev.ops-impl` each reach their own seat), and leaves the siblings as the restore left them. After a yes, each seat prints `fresh-primed (<session>)`, or `not started` with the daemon's guidance, or, on a timeout, that the outcome is unknown and to check `rig seat status`. `up` names the rig from the restore result. The rig-spec OMP Restore bullet is updated.

Limits: the daemon's own awaiting-decision reason text still says "Re-run with --fresh"; that's a separate follow-up.

Finishes #1332, by @adampog, who found the refused hint and proposed the one-seat command.

Cost: source +55/-45 (3 files), docs +1/-1 (1 file), tests +160/-15 (3 files). No new refusal or error code.

Co-authored-by: Alan Dampog <[email protected]>
2026-10-22 18:25:53 -07:00
Mike SchwarzandNash Aschenbath 844459815d fix(queue): a claim waiting on its owner's input reads needs-input (#1801)
A claimed queue item whose owner is waiting on a person's input read `stalled-after-claim` once the grace period passed, with evidence that never mentioned the input. The input count was already reported elsewhere (`waiting.liveness.needsInput`, the execution view's `needs_input`), but not in the pickup answer itself.

Now the pickup receipt reads `needs-input`, with the reason (for example `owner needs input: permission_prompt`), for a claimed item whose owner has outstanding input. The stall evidence moves into a `stall` field that recovery keeps reading, so the stuck sweep opens and keeps the same stall finding it would on main, and backstops and manual dispositions are unchanged. Queue reads, `rig view show pickup` and the execution view's lanes carry the new state, and an old claim still parks as stalled. Unclaimed, blocked and closed items are unchanged.

Limits: the compact `rig queue show` line shows the input reason but not the second stall explanation (the JSON receipt has both). Pending items that kept their claim stay outside the pickup view and the stall sweep, as before.

Finishes #1313, by @Aschenbath, who proposed telling input waits apart from stalled claims.

Cost: source +32/-9 (4 files), docs +13/-1 (1 file), tests +112 (1 file). No new refusal or error code.

Co-authored-by: Nash Aschenbath <[email protected]>
2026-10-22 18:11:54 -07:00
Mike Schwarz 5882d47b95 fix: name where custody wasn't checked; show a mixed revise example (#1798)
The stuck sweep's custody finding was titled `Stuck sweep: successor-verification-required on <row>`, which didn't say what had gone wrong or where.

Now the title says where custody wasn't checked: `Custody not checked on host <host> for <row> (N min)` when the successor row is on another host, or `Custody not checked: no successor of <row> in this store (N min)` otherwise. A seat address is never read as a host. The finding's body, routing, tags and dedupe are unchanged, and findings already open keep their old title.

`rig workflow revise --help` now has an Examples block with a mixed change: one reason refuses the whole proposal, so put the protected step back as it was bound, keep the compatible addition, inspect again, then run the printed apply line.

Cost: source +33/-2 (2 files), tests +4/-2 (3 files). No new refusal or error code.
2026-10-22 15:40:29 -07:00
Mike Schwarz 6cbb92c992 fix(tui): --demo draws its fixture without probing for a daemon (#1797)
With no daemon running, `--demo` opened the crash-cart screen instead of the demo team, because demo mode has no daemon client and the launch fell through to the crash-cart probe.

Now the launch skips that probe in demo mode, so the demo fixture draws. The normal launch is unchanged. The timezone setting is still read from local configuration under `--demo`; with no `rig` installed, the demo shows a "ui.timezone unavailable" warning and still draws.

Cost: source +2/-1 (1 file), tests +45 (1 file). No new refusal or error code.
2026-10-22 15:35:56 -07:00
Brad Vincent 6ca7d25b12 feat(seat): add optional identity preconditions to stop (#1792)
A client that shows a person a seat and asks "stop it?" had no way to make sure the stop hit the seat that was shown. A relaunch, handover or rebind in between could stop a different session.

Now `GET /api/seat/stop-precondition/:seatRef` returns the seat's node and latest session under a `seat-stop.v1` marker, and the existing `POST /api/seat/stop/:seatRef` accepts that tuple as an optional `expected`. With it, the stop runs inside the existing lifecycle lease and checks the selection again before the detach, before the kill and before saving the stop. A change returns 409 `selection_changed`, and nothing is killed. A detach that had already happened is reported as `partialEffect`. A kill or save whose result is unknown returns `stop_uncertain`, so the client inspects before retrying. A checked stop needs the delivery guard (`guard_unavailable` otherwise), and a malformed explicit `expected` is a 400.

A stop without `expected`, including `rig seat stop` and the TUI, behaves exactly as today. Setters are unchanged.

Cost: source +141/-16 (3 files), docs +11 (1 file), tests +210 (1 file). New refusal codes appear only when a client opts in.

Thanks to @bradvin for reshaping this to the smallest version we agreed.
2026-10-22 15:31:30 -07:00
Brad Vincent 955b63321b fix(codex): omit generated writable roots for read-only profiles (#1791)
A Codex seat launched under a named profile with `sandbox_mode = "read-only"` was given OpenRig's generated writable `--add-dir` roots (Git metadata and the pod's state folder), and current Codex refuses writable roots under a read-only sandbox at startup, so the seat couldn't launch.

Now those generated roots are left out when the profile really controls a read-only launch, on fresh, resume and fork. They're kept, as today, whenever something above the profile could make the launch writable: a project `.codex/config.toml` from the working directory upward with a writable `sandbox_mode` or named permissions, or project trust that the files on disk can't settle. Explicit posture overrides, rebuild permission envelopes and a running process's own roots are unchanged, and no profile or project file is rewritten.

Cost: source +43/-4 (1 file), tests +225/-4 (1 file). No new refusal or error code.

Thanks to @bradvin for working it through three rounds.
2026-10-22 14:45:49 -07:00
Mike Schwarz 0a642d66b1 feat(daemon): bring Herdr tiles back after a Herdr restart (#1796)
After a Herdr restart or upgrade, OpenRig's tiles came back as empty shells while the seats kept running.

Now, once a view is on screen, the Herdr adapter sends one session-only report per tile: `pane.report_agent_session` with the runtime's label (`claude`, `codex` or `pi`) and the tile's reattach command, so Herdr's own restore reattaches the tile. No state is reported, so Herdr's screen detection stays in charge. Herdr accepts the report only once it has detected the tile's agent, so a `resume_not_accepted` refusal is retried, up to seven rounds 500 ms apart (at most 3 s of waiting plus each call's own time). After that, or on any other refusal, the tile gets a note and keeps today's reopen-the-view route. Each tile's command carries its own `OPENRIG_TILE`, so two views of one seat both come back. Tiles over ssh, runtimes Herdr doesn't recognise, names with an apostrophe, and a reply whose tree can't be matched are noted rather than reported. Opening a view never fails because of a report.

Cost: source +113/-1 (3 files), tests +189 (1 file). No new refusal or error code.

Refs #1761 (thanks to @alexveli1 for the in-place reconciler idea).
2026-10-22 14:24:46 -07:00
Mike Schwarz a0c3e9a779 chore: declare npm 10 or newer in the root engines (#1795)
A contributor on npm 9.x who ran `npm ci` on a fresh checkout got an `ENOENT` on the unbuilt TUI binary with no hint why, because the root `engines` named only Node.

Now the root `package.json` declares `npm >=10` beside the Node range, and the lockfile's root entry records the same. npm 9 prints its `EBADENGINE` warning naming the floor; it warns and doesn't refuse. `CONTRIBUTING.md` already says to use npm 10 or newer.

Cost: config +4/-2 (2 files). No dependency, version or script changes; no new refusal.
2026-10-22 13:32:36 -07:00
Mike Schwarz 6b9224f852 fix(daemon): retain shutdown timing in existing diagnostics (#1794)
A slow daemon stop was hard to diagnose after a later shutdown replaced the single shutdown receipt, because the timing lived only there.

Now the CLI's stop error includes the time this process requested SIGTERM, and the daemon's existing shutdown log lines carry the PID, the time the shutdown callback started and the time it completed. A target that was already dead gets no invented request time. The callback time is when the handler ran, not when the kernel delivered the signal. Stop behaviour, ownership checks, budgets and the receipt format are unchanged.

Cost: source +8/-3 (2 files), tests +54/-3 (2 files). No new refusal, setting or error code.
2026-10-22 13:15:23 -07:00
Mike Schwarz 73347b7f40 fix(cli): wait for slow owned daemon startup within one budget (#1793)
A healthy owned daemon that took 400 ms to 1 s to answer its health check could lose every 250 ms startup probe, so `rig daemon start` treated it as failed and terminated the child.

Now startup readiness uses one 20 s budget, shared by the primary probes, the listener checks and the backoff, and each startup probe may take up to 2 s within what's left. Once the budget runs out no new probe starts, but a result already verified is still used, with the usual liveness and publication rechecks. A late primary read can't publish through an unverified secondary listener; an on-time primary whose secondary check runs past the budget still publishes with its warning. The pre-spawn, status and stop timing is unchanged, as are the identity, liveness, publication and owned-child cleanup checks.

Cost: source +21/-5 (1 file), tests +95 (1 file). No new refusal, setting or error code.
2026-10-22 12:26:28 -07:00
Adam Whitehurst 0e6af39ad1 fix(daemon): a seat added to an existing pod gets the pod's startup files, and the add stops when a required inherited file is missing (#1701)
A seat added with `rig add` or `rig grow` into an existing pod got the rig's culture file and rig startup files (#1632), but not the pod's own startup files. And when an inherited culture file or required rig file was missing, the add went ahead and the agent then failed at delivery.

Now an added seat gets the pod's startup files after the rig layers, in the same order as a seat declared in the rig file. An add also stops, before any seat exists, when a file it would inherit must be present and is missing or unreadable: a `required: true` rig or pod startup file, or the rig's declared `culture_file`. The error names the file, its resolved path and the rig or pod that declared it. `rig grow --new-pod` stops the same way when at least one added member reads startup files. Optional files, restore-only files, and members whose runtime reads no startup files (terminal and community-harness members) are added as before.

Rigs created before #1632 have nothing recorded and are unchanged. Rigs created on main since #1632 have the rig layers but no pod files, so they get no pod layer, and an add into one now stops when its culture file or a required rig file is gone. A saved startup list is still replayed on fresh launch and restore without re-resolution.

Cost: source +84/-13 (2 files), tests +283/-3 (1 file). One new add-time error, for a missing required inherited file, accepted by the maintainers.
2026-10-22 12:23:06 -07:00
Mike Schwarz 284626341a docs(skills): say how to bring Herdr tiles back after a Herdr restart (#1788)
After a Herdr server restart or update, Herdr restores its layout, but OpenRig's tiles come back as empty shells while the seats keep running, and the openrig-herdr skill didn't say what to do.

Now the skill says it: reopen the view with `rig terminal open <view> --provider herdr`, which places the tiles in a fresh workspace. That's the stopgap while the real fix, reporting each tile's reattach command to Herdr so its own restore reruns it, waits on a live check. Both copies of the skill change together, with the pinned edge digest.

Cost: docs +6 (two copies of +3), generated digest +2/-2. No code change.
2026-10-22 12:04:59 -07:00
Mike Schwarz d30a37a241 fix(daemon): decide each combined queue wake member by the level in force when it's sent (#1781)
After #1770, a level set while a combined queue wake waited for a busy seat's lease didn't apply to that wake. A routine member could type at `urgent-only`, or an urgent one could be held together with a routine one (#1770's stated limit).

Now, inside the guarded send, each member is re-admitted with its own source and urgency against the configuration the lease sees, through an internal send option that's never accepted from a request. Only members matching the decided outcome go together. The others return to their pre-claim delivery state and are delivered on their own, awaited, so the drain finishes only when every member has an outcome. Every delivery pass, whether post-commit, successor or the startup drain, keeps one attempt history that nested re-deliveries inherit, so a member already sent in the pass, confirmed or not, is never sent again in that pass, and each released member gets exactly one first attempt. Ordinary `off` combining, and a returned result with its resume as one send, are unchanged.

Cost: source +51/-12 (2 files), tests +95/-2. One internal send option; no flag, migration, refusal or error code.
2026-10-22 10:31:56 -07:00
Piotr Durlej c22494fc69 feat(cli): rig_telemetry_events reads the event metadata page over MCP, so a seat without a shell can follow the log (#1596)
An agent in an app with no shell, reaching OpenRig only through `rig mcp serve`, couldn't follow the daemon's event log.

Now the MCP server has a 24th tool, `rig_telemetry_events`, over the existing bounded read (`GET /api/telemetry/v1/events`, which `rig telemetry events` uses). It takes the CLI's parameters (`cursor`, `start` latest or retained, `rigId`, `nodeId`, `limit` 1 to 128) and returns the same envelope: event metadata only, never payloads, with `coverage.gaps` naming history it couldn't read, a daemon restart included. `rigId` takes a rig name or id, resolved as the other MCP rig tools resolve it, and a handle no rig matches is an error rather than an empty page that would read as a quiet rig. The daemon's 400 and 503 answers come back as tool errors with its body. The `openrig-user` skill and the telemetry reference name the tool. No daemon route, SSE, event-store or queue behaviour changes.

Cost: source +34 (1 file), tests +83/-4 (2 files), docs and skill +8/-4, generated digest +1/-1. No new setting or migration; the unknown-rig error is in the new tool only.
2026-10-22 08:15:15 -07:00
Mike Schwarz 5ab3eeda09 fix(daemon): keep workflow keepalives through a seat swap (#1782)
After #1777, a seat swap still stopped a workflow keepalive that the retiring occupant had registered with itself as the fallback target, even while the keepalive was waking another seat that owned the workflow's current step. That workflow lost its backstop wake (#1775's last case).

Now the swap never stops a workflow keepalive, the same way it already keeps a blocked row's park timer. A keepalive picks its recipient when it fires: the frontier rows' owner, then the workflow's creator, its observers, and its stored target last. When that recipient is this seat, the successor now owns the step, so the wake is the right one. A kept keepalive still ends with its workflow: its policy goes terminal when the workflow is missing, inactive or past that packet, and the runtime disarms it as the packet advances, ends or is aborted. Every other job is judged as #1777 does.

Cost: source +4/-2, tests +36. No new refusal, setting or migration.
2026-10-22 08:05:08 -07:00
Luis Araya c3ba7223a7 fix(daemon): retain culture_file and startup blocks across rig export (#1657)
`rig export` dropped a rig's `culture_file` and rig-level `startup` block (#1631), though `rig up` from a spec stores both since #1632. So an exported team brought up elsewhere lost its culture and startup files.

Now the exported spec carries both, read from the rig's stored startup layers. The YAML export route also sends the rig's recorded root in an `X-Rig-Root` response header, URI-encoded so any path is a valid header value. After writing the file, `rig export` checks each referenced culture file and each required startup file against the output folder. For one that doesn't resolve, it warns that a rig launched from the export will fail its startup until the file is copied next to it, or the export is imported with `--rig-root <recorded root>`. The check never blocks the export. JSON results and request headers are unchanged.

Cost: source +72/-4 (4 files), tests +105 (3 files). No new refusal, setting or migration.
2026-10-22 07:55:25 -07:00
Mike Schwarz 013865dca2 fix(cli): keep rig ps --nodes columns apart (#1780)
#1614 widened the compact ACTIVITY cell so a count fits, but other cells of `rig ps --nodes` still ran together: a 38-character SESSION such as `orch-rebuild-historian@v-openrig-build` ran into LIFECYCLE, `needs-input (screen)` overflowed its cell, and the `--full` ACTIVITY cell (12) cut the count.

Now both tables join their cells with one space, as the rig table already does, so no cell ever runs into the next and a whitespace split gets each column on its own. The separator sits on top of today's widths rather than inside them, so a real 38-character name isn't cut: the default row grows by 5 columns. The `--full` ACTIVITY cell widens from 12 to 17, as in the default table; the `--full` row grows by 20. JSON output is unchanged. In `--full`, a 38-character SESSION is still cut with `…` (34 wide), and `needs-input (screen)` still shows as `needs-input (scr…`, but neither merges into the next column.

Cost: source +7/-3, tests +35/-9. No new refusal or setting.
2026-10-22 07:49:22 -07:00
Mike Schwarz 86a587affc fix(codex): carry observed permissions through seat rebuilds (#1779)
Rebuilding a Codex seat in place (`rig handover --source rebuild`) relaunched it with fresh-launch grants: network on and extra writable directories, even when the running agent had a narrower sandbox (#1773). So a deliberately restricted Codex seat came back wider after a rebuild.

Now the rebuild carries the predecessor's observed permission policy when the observation matches the current pane, process, conversation and working directory, and its shape is a supported sandbox and approval pair. A pending permission or policy choice, or a differing declared successor profile, takes precedence. Re-selecting the mode that's already saved (`rig seat set-permissions <seat> --mode <same>`, or the same Codex policy again) now records that choice and its audit event, so it also wins over the carried policy. A missing, stale or unsupported observation keeps the existing launch behaviour, and both JSON and human output say the permissions weren't verified or carried. The result reports policy emission apart from native equivalence, which wasn't tested with a real rebuild. Fresh launches are unchanged.

Cost: source +206/-12 (9 files), tests +193/-4 (5 files), docs +6. Per Codex rebuild: two process observations, a bounded record read and one indexed lookup. A carried policy gets no fresh-only grants, so work that needs them takes an explicit permission choice. No new setting, dependency, migration, confirmation or refusal.
2026-10-22 07:45:02 -07:00
Mike Schwarz 9b11acc8ce fix(daemon): keep reminders a retiring occupant registered for other seats (#1777) (#1777)
Rebuilding, handing over or freshly launching a seat stopped every active reminder its retiring occupant had registered, whichever seat each one woke (#1775). So an operator's reminders for other seats' parked work died with the operator's occupant, and that work lost its timed backstop.

Now a swap stops only the old occupant's jobs that would wake its own seat. Each job is judged by its delivery target, taken the way the policy engine takes it (one shared helper: a context-usage job's stored target, otherwise the policy's spec target), and that target counts as this seat when it's the retiring or successor name or any of the seat's current logical or physical addresses. Reminders aimed at other seats keep running, as reminders aimed at a handed-over seat already did. The generation scope and the park-timer exemption are unchanged, and there's no registration refusal or alias ban.

Known limit: a workflow keepalive picks its recipient when it fires (the workflow's current frontier owner), so a swap still judges it by its stored or spec target; a keepalive the retiring occupant registered with itself as fallback while another seat owns the frontier still stops. A follow-up makes the swap keep workflow keepalives, which are bound to the work.

Cost: source +80/-23, tests +89/-15. No new refusal, setting or migration.
2026-10-22 07:21:34 -07:00
Mike Schwarz f1668cfcbb feat(seat): interruption levels on the typing guard, and rig send --urgent (#1770) (#1770)
A person can now choose, per seat, how much automatic input may type into that seat's terminal (0.7.1 slice 21). The levels ride the existing `rig seat set-typing-guard --mode`: `off` (always interrupt; the agent-seat default, unchanged), `openrig-urgent` (OpenRig's own messages and anything urgent), `urgent-only`, and `hold` (full quiet). A terminal member with no setting defaults to `sends-only`. Anyone can mark a message urgent, with `rig send --urgent` or a queue row at priority urgent or critical, and it shows as `Urgent (marked by X):`. Held input stays readable and is never replayed, and a queue row lands whatever the level; a held wake reads as held, not stuck.

One admission rule inside the existing guard decides each input, and what triggered a wake decides its source: the park timer, ladder retries, auto-unpark and OpenRig-created rows are OpenRig's. Send, broadcast, queue create and the cross-host forward never take those markers from a request; the trusted local outbox recorder accepts caller tags by design. Urgent keystrokes for an explicit prompt answer go out exactly, with no label, and an SSH `--host` send forwards `--urgent`. Queued wakes are combined only when each member, admitted at the seat's level, has the same outcome, so a returned result and its waiter's resume still go as one send. Draft protection and the permission-prompt guard are unchanged; at the two middle levels, a draft holds even an urgent override.

Visible changes: an urgent or critical queue row's wake now starts with `Urgent (marked by …):` at every seat, defaults included; for existing `hold` users a held wake is no longer re-nudged or reported stuck, with one held notice instead; lifecycle work is refused at the middle levels as at `hold`; an older daemon types OpenRig's notices at an explicitly set `sends-only`.

Known limit: if the seat's level changes while a combined wake waits for its delivery lease, that one delivery uses the level read at grouping, so one routine wake can type at a newly chosen `urgent-only` or one urgent wake can be held behind a routine one. It needs correlated wakes of different urgency, a busy lease and a level change in that window; the follow-up rechecks membership at the send boundary.

Cost: source +275/-59, tests +559/-46, docs +1/-1. One flag and three `--mode` values; no migration, new refusal or error code.
2026-10-22 07:16:53 -07:00
Mike Schwarz 1e2ba1f137 Stop a managed Claude launch from rewriting the worktree's permission mode (#1776) (#1776)
Adding or freshly launching a managed Claude seat rewrote the worktree's own `.claude/settings.local.json` permission mode to `acceptEdits`, overwriting a person's `auto` or `denyAll` (#1774). The shared Claude settings fragment carried that value, and its scalar won the merge. A managed session never needed it, because every launch passes its own mode flag, which outranks local settings.

Now the fragment no longer carries `defaultMode`, so a person's mode and rules are left as they were. Because the daemon's Claude permission check used that file as the session's mode, configuration now follows launch-mode precedence and keeps the file's default in the detail. A seat with no mode in the file, or with `auto` or `denyAll`, reads aligned. Native enforcement stays explicitly unverified, and the CLI front door allows only that observation gap when the configuration is a valid launch mode; unreadable, invalid or conflicting settings still keep the TUI closed. The printed Claude resume command now carries the selected mode flag, as the Codex one does.

Files already rewritten by earlier versions aren't repaired; set the intended default back once by hand. Manual Claude starts in a worktree now use the person's own default.

Cost: source +24/-13 plus the fragment's -3, tests +73/-55, README +4/-3. No new setting, refusal or error code.
2026-10-22 06:54:49 -07:00
Shravan Sumanthanan 37465f92c3 fix(cli): allow for count suffix in compact ps activity column (#1614) (#1614)
In the default compact `rig ps --nodes` table, the ACTIVITY cell was 14 characters wide, so an activity with a count, such as `needs-input x1`, ran straight into the WORK column (#1588). The cell is now 17 characters, which fits the count suffix with padding before the next column. Tests pin the padding and the column offsets.

Cost: source +1/-1, tests +36/-1. No new refusal or setting.
2026-10-22 06:51:15 -07:00
David Cruwys f81d38f2e7 feat(cli): rig up <spec> --name <rig> launches a spec under a chosen rig name (#1692) (#1692)
A rig always took its spec's `name:`, so one spec could run as only one rig at a time (Discussion #1647). Now `rig up <spec> --name <rig>` launches the spec as a rig called `<rig>`: `--name alpha` and `--name beta` give two rigs, with seats such as `work-worker@alpha` and `work-worker@beta`. The rig keeps that name for `rig ps`, `rig down`, `rig up <rig> --existing`, snapshots and restore. Without `--name`, nothing changes.

The daemon sets `name:` in the spec text in memory, right after reading it (a spec path, a v1 bundle or a v2 pod bundle) and before anything parses it. The file on disk isn't changed. So validation, the running-name guard, the archive of a stopped same-name rig, the per-name import lock, the Compose project name, `rigs.name` and seat names all see one name. No new storage or migration, and no second naming mechanism.

Cost: source +99/-14, tests +689/-5, docs +4/-2. That's about 7 test lines per source line, after the author dropped tests that overlapped others. No new refusal or error code.
2026-10-22 06:14:13 -07:00
okle100 b6609ae46c fix(pi): a Pi runner's idle heartbeat is its liveness signal for the activity ladder (#1702) (#1702)
A Pi seat with no turn yet in its generation read as stuck, because nothing it posted counted as a sign of life. Now the Pi runner posts an idle heartbeat, and the activity ladder takes it as the seat's liveness signal.

The beat is sent only after the runner's last post was a Stop (or before anything has been posted), so a seat that is working never beats idle; a compaction or turn that ends without a Stop simply leaves the seat unknown until its next turn ends, as before. Any posted Stop restarts the beat clock, so the next beat comes a full interval later. An idle report that carries an interval lapses to unknown after two intervals without a beat. On restart, the latest activity is read from the node's newest 200 records before the session filter, so the read stays bounded however long the node's history. Activity posts now start only after the previous one settles, so a beat in flight can't arrive after a turn's first active post and make a send waiting for idle type into a working seat. Each post keeps its 1.5 s timeout, so a slow daemon delays the posts behind it by that much each, and a failed post never blocks the next.

Cost: source +85/-20, tests +242/-1, docs +7/-1. One 15-second check timer per Pi seat, and about 480 idle beats per idle seat per day (one every 180 s). No new refusal, setting or dependency.
2026-10-22 05:56:10 -07:00
Mike Schwarz 2971313eee Report incomplete resume metadata after no-launch reconciliation (#1767) (#1767)
After a parent recovery on 2026-10-22, 11 Codex seats came back bound by `rig reconcile-session --no-launch` with empty resume metadata, and nothing in reconcile's result said so; only the operator's own receipts showed it. The shared capture method swallowed its outcome.

Now that method returns the adoption capture outcome, and reconcile reports, in text and in `--json`, whether future resume metadata is recorded, missing, unconfirmed or not applicable. When it's incomplete, the output points to the existing audited `rig seat set-resume-token <session> --token-stdin --reason`, to use after confirming the live conversation's identity. Binding success still succeeds, and the bind and create-and-bind callers ignore the additive return as before. No token is copied from a predecessor, no native input or probe is added, and continuity stays unverified: "recorded" means valid-shaped metadata exists. A legacy generic `probe_timeout` reads as not obtained, not as a measured timeout. Tokens and exception details never enter the result.

Cost: source +50/-5, tests +84/-3. One indexed session-row read after the existing capture. No new refusal, setting or error code.
2026-10-22 04:42:53 -07:00
Mike Schwarz 9ae6cee65d Don't proclaim the same model divergence again after a restart (#1766) (#1766)
A seat running on a model nobody chose is announced once per occupant generation, but the monitor kept that settled set only in memory, so every daemon restart announced the same unchanged divergence again.

Now the proclamation carries its content-derived occurrence id (node, generation, pin and effective model), and the existing `seat.model_divergence` event stores it. Before proclaiming, the monitor asks the daemon's event log whether that occurrence was already received, with one query on the existing `(node_id, type, seq)` index. Only an event where some channel delivered or retained the alert counts. One whose channels all failed or were deferred is proclaimed again after a restart, as before. A changed effective model, a changed pin or a new generation still alerts. If the log can't be read, the monitor proclaims and logs why. No new table: the event gains one optional field, and older events simply don't match.

Cost: source +36/-7, tests +76/-0. No new refusal or error code.
2026-10-22 04:16:50 -07:00
Mike Schwarz 7c19588d4e Warn when daemon database selection crosses home or shares a live database (#1763) (#1763)
A second daemon can open another home's database without anyone noticing: on 2026-10-22 a test daemon inherited OPENRIG_DB, opened the parent's database, and its startup reconcile marked sessions detached there. Nothing said which database it had opened.

Now a starting daemon warns, naming both paths, when its database selection resolves outside OPENRIG_HOME, and the advice names --db, db.path, OPENRIG_DB and RIGGED_DB so the operator can correct whichever override chose it. It also warns when another observed holder of the database is a live daemon. A matching daemon.json's path, mtime and recorded home are labelled as recorded, never as the live holder's current home. `rig status` shows the boot observations in text and in an optional `daemon.databaseWarnings` JSON field. Explicit overrides are still honoured, and startup, reconcile and writes continue: these are diagnostics only. No warning is not proof of exclusive use.

Measured on macOS with Node 22: about 58 ms added at boot with no other holder, 79 ms with a live holder. Holder inspections are capped at four and reuse daemon.json, open-file and health evidence. No dependency, lock or schema.

Cost: source +85/-11, tests +80/-0. No new refusal or error code.
2026-10-22 04:04:08 -07:00
Mike Schwarz 514f68bafd perf(daemon): a light /api/scopes list, and no narrative read the plain list drops (#1765)
Studio's Projects list needs each project's missions with counts and slice statuses, and the only read was `GET /api/scopes?project=<id>`: about 2.0 MB and 0.6-1.8 s for openrig, holding the daemon's healthz meanwhile. Measured first: readiness is about 90% of the bytes, and proof pairing plus readiness most of the time; the PROGRESS.md narrative reads the plain list discarded cost about 7 ms.

So `?counts=1` returns each slice's `dirName`, `id`, `displayName`, `status`, `stage`, `locks` and `sourcePath` from one node-file read, with no readiness or proof (about 171 KB). `detail=1` wins if both are given. The default list keeps its exact response shape, stops reading PROGRESS.md for the narrative it already stripped, and drops a repeated `workSource` call that returned the same path. The rows come from a new `projectSliceHeader`, which `projectSliceScope` now starts from.

Cost: source +53/-17, tests +85, docs +6. No new refusal; one added query parameter.
2026-10-22 03:52:33 -07:00
Mike Schwarz 45ada04c87 fix(daemon): read an idle Claude seat under an update banner or /clear hint (#1762)
An idle Claude seat with Claude's update banner (`Update available! Run: …`) or a `/clear` suggestion as the last line below its mode bar read as `unknown`, so `rig send --wait-for-idle … --verify` waited to its deadline and didn't send. The reader took that line for the footer, so it found no complete input frame, and the empty prompt sat outside the last-three-lines fallback. Now both lines join the existing list of Claude status warnings the reader steps over. The existing rules decide everything else: a warning is never idle evidence by itself, and busy, asking, draft and selection screens still win before idle. The input-box reader shares the same anchor, so it agrees.

Limits: the below-the-bar `/clear` placement comes from the report, with no capture of it; a hint that wraps onto two lines still reads `unknown`.

Reported by @geoff-dhango on #1641.

Cost: source +4, tests +24/-1. No new refusal or flag.

Refs #1641
2026-10-22 03:41:57 -07:00
Mike Schwarz 5fa27c5b02 fix(cli): create and grow terminal seats with terminal defaults (#1760)
`rig create x --runtime terminal`, and `rig grow` with a terminal runtime, paired the terminal runtime with the default orchestrator agent, so the generated spec failed with a wrong-agent or profile error. Now the shared default-agent helper takes the runtime: `terminal` gets `builtin:terminal` with `profile: none` before any library lookup, and every other runtime keeps today's default. Both grow paths (a new pod and an existing pod) share the same member generation. A runtime with no shipped default says so and points to `rig up <spec>`.

Found by T4 in a desktop-join dry run on 0.6.14.

Cost: source +8/-7, tests +36/-0. No new flag or refusal.
2026-10-22 02:57:55 -07:00
NIKHIL e124a74336 fix(ui): preserve file drafts and their revision during refresh (#1758)
In the web UI's Files editor, every changed background read (a focus refetch, or the invalidation after a save) reset the textarea, so text being typed could be lost. Now the editor keeps the draft together with the revision it was based on. A clean editor still follows background reads. A dirty draft keeps its text and base until save, Cancel or Refresh. Saving sends the base revision, so an external edit returns a conflict instead of being silently overwritten, and a successful save moves the base to the written revision.

Cost: source +31/-17, tests +109/-0. No new refusal or flag.

Contributed by @Nikhi00718.
2026-10-22 01:07:05 -07:00
NIKHIL a3d4581e4b fix(stream): drain replay arrivals before switching to live writes (#1757)
`rig stream watch` buffers items that arrive during its initial replay. The handler switched to direct live writes before draining that buffer, so while each buffered item was being written, a newer arrival could be written ahead of older buffered ones, and a watcher saw items out of emitted order around connect time. Now the switch happens after the drain, and arrivals during the drain join its tail. This is the shape the chat watch already uses. It applies to both `/api/stream/sse` and `/api/stream/watch`; replay selection and size are unchanged.

Cost: source +3/-1 (one moved line and a comment), tests +27/-0. No new refusal or flag.

Contributed by @Nikhi00718.
2026-10-22 00:56:03 -07:00
NIKHIL e83a1e8099 perf(usage): group cost history without copying each prefix (#1756)
`rig usage cost` grouped each seat's context samples by copying the whole growing array for every row, so the work grew with the square of a seat's samples. The projection runs synchronously in the daemon; with 20,000 samples for one seat (about seven days at the default 30-second cadence) a call took about a second. Now each row is pushed onto one array per seat. The SQL order (`seat_session`, `id`), seat isolation and the `seatSession` filter are unchanged.

Cost: source +5/-1, tests +23/-0. No new refusal or flag.

Contributed by @Nikhi00718.
2026-10-22 00:55:14 -07:00
Devulapalli Naga Sri Vaishnavi 4e8fcb875f fix(daemon): avoid a flash of output when a terminal view returns to live (#1663)
Scrolling a terminal view back to live repaints the current screen with two tmux calls, while the tail keeps reading the pipe file and sending new bytes. Output the tail read between the screen capture and the snapshot being sent used to reach the viewer and then be painted over by the snapshot: a brief flash. Now `scroll(sub, 0)` keeps that subscriber out of the live fanout until the repaint is sent, so those bytes are skipped instead. The viewer ends with the same screen either way. Output that tmux wrote before the capture but the tail reads after the snapshot still arrives after the snapshot; this change doesn't reorder the stream.

Cost: source +8/-2, tests +45/-0. No new refusal or flag.

Contributed by @Vaishnavi220506. The wording here replaces the PR title's "keep ... ordered", which the review found overstated.
2026-10-22 00:48:34 -07:00
Mike Schwarz 3ee8ba7925 fix(startup): distinguish generated identity from assignments (#1754)
A new agent's first message is labelled as generated context, not a task. A fresh identity-only start wraps its identity block as "OpenRig-generated identity context (not a project task)" through "End of generated identity context.", then asks the agent to run `rig whoami --json` and reply with its identity and readiness for an assignment, and says repository history, tool descriptions and its own replies are not assignments. A start with a bundled task, a restore obligation or a challenge keeps its existing flow. This is guidance aimed at agents that invent a request at startup, not a demonstrated cure.

The longer first message exposed a test-fixture limit: the stub runtime never read stdin, so the identity message plus later sends filled its terminal's unread input queue and a verified send to a narrow pane read unverified. The stub now drains stdin; send verification and its assertions are unchanged. Limit: if the stub can't write its READY sidecar, it now waits instead of exiting 1 (test stub only).

Cost: source +9/-2, tests +13/-5. No new refusal, setting or send.
2026-10-22 00:08:20 -07:00
Mike Schwarz 088afc6e08 docs: Ubuntu 22.04's tmux 3.2a can stop every agent when a seat's session closes (#1753)
On Ubuntu 22.04, the packaged tmux is 3.2a, which exits its whole server when asked about a pane or session that has just closed. OpenRig's seat checks could send that query when a seat's session closed, stopping every agent on that tmux server. A new entry in getting-started's restart section, beside #980's, says what happens, how to tell (`tmux -V`), and what to do: put tmux 3.4 or later first on `PATH` (for example by building a current release), then restart the daemon from a shell that sees it. It says newer versions weren't tested against this crash.

This describes released versions. #1755 stops the crashing query on main, so the entry gets a "before 0.7.1" qualifier at 0.7.1's docs refresh (recorded in that mission's notes).

Cost: docs +18/-0.

Refs #980
2026-10-21 23:29:32 -07:00
Mike Schwarz 73258fa592 fix(daemon): don't ask tmux about a seat the batched activity read lacks (#1755)
On Ubuntu 22.04's packaged tmux, 3.2a, a seat's session closing no longer makes OpenRig's seat-activity check ask tmux about it by name. tmux 3.2a exits its whole server on that query (`display-message -p -t <vanished target> '#{window_activity}'`), which stopped every other agent on the server.

The 1 Hz sweep reads every session's window activity in one `tmux list-windows -a` call, then asked per target about each seat the batch lacked. A seat is missing from the batch when its session has closed, which is the crashing query. Now, when the batch was read, a seat it lacks has no activity signal for that tick and no per-target query. For a session that does exist, the per-target read returned the same non-timestamp the batch left out, so no signal is lost. With no batch (tmux couldn't be listed), and for a direct `pollSeat`, each seat is still read per target.

Limits: the race is narrowed, not gone, for a session that closes between the batch read and the same tick's other tmux calls. The structural-activity capture (`capturePanesContent`) also names each due seat's session before consulting its listing; whether those commands crash 3.2a is untested.

Cost: source +7/-5 (the logic change is one condition; the rest is comments), tests +4/-3 (one existing assertion). No new refusal or flag.

Refs #980
2026-10-21 23:15:28 -07:00
Mike Schwarz 4f08b6f25f fix(cli): let rig fork take a rig name, as its own help example does (#1752)
`rig fork`'s own help examples pass `--rig openrig-delivery`, a rig name, but `--rig` went to the daemon as an id, so the copied example failed with `rig_not_found`.

Now `--rig` resolves through the CLI's existing `resolveRigHandle`, as `rig expand`, `rig edge` and `rig add` do: an id matches first, then the one active rig with that name. A name two active rigs share stops with `rig_ambiguous` and names the ids, with no fork request sent. A handle that matches nothing, or a rig list that can't be read, passes through as before, so the daemon answers for that exact id. The daemon's fork route is unchanged.

Cost: source +11/-2, tests +34/-1, docs +1/-1. No new flag; the one refusal is the shared resolver's existing `rig_ambiguous`, for a name that failed before.

Refs #1681
2026-10-21 23:08:08 -07:00
Mike Schwarz eab270584b fix(terminal): use the current Herdr session over SSH (#1748)
Working the documented way (ssh to a server, then run `herdr` there), `rig tui` → TERMINALS → open a rig now places the view in the current Herdr session. Before, it said "This is an SSH session; no desktop window is opened over SSH" and opened nothing, because a Herdr server started from an SSH login passes the SSH variables to every pane, and the SSH check ran before the check for a Herdr pane.

Now the CI check comes first, then the current-Herdr check (which still requires the caller's Herdr socket to match the daemon's), then the SSH refusal. The current-Herdr path places the view through Herdr's own socket and never opens a desktop window, so opening a NEW desktop window over SSH still refuses as before.

Cost: source +1/-1 (one line moved), tests +7/-4. No new refusal or flag.
2026-10-21 21:35:23 -07:00
Mike Schwarz c68c20682e fix(cli): start with a warning when only a secondary listener is unverified (#1743)
On a host where the daemon's own endpoint answers but a secondary listener's self-check stays inconclusive (#1714: Tailscale up, the check returning ECONNRESET), `rig daemon start` now starts. It records the daemon as a normal start does, so `rig daemon stop`, `status` and the next `start` work, and it prints `Warning: daemon started (pid …), but its listener on <host> could not be verified …` to stderr. After #1716 the daemon survived but the command failed, and stopping or restarting it needed a manual cleanup.

A listener that is definitely missing still fails the start and cleans up, now also when another listener is uncertain at the same time. The primary endpoint must still answer with the spawned process's own id in the last round, and an uncertain primary keeps #1716's behaviour.

Known limit: the start still waits out its retries before warning, about 20 seconds (about 40 when each check times out). The TUI's "restore everything" path runs a plain `rig daemon start` with a 30-second timeout, so in the slower case it can stop the start before it records the daemon; the TUI's other two start paths bind loopback only. That isn't a regression: main waited as long and then failed.

Cost: source +21/-14 (1 file), tests +102/-25 (3 files). No new refusal, flag or error code.

Refs #1714
2026-10-21 21:29:14 -07:00
Mike Schwarz 0588548c06 feat(daemon): the before-install bundle view shows a member's launch additions (#1745)
Inspecting a bundle before installing it now shows when a Claude member brings its own agent, prompt file or plugin folders. They appear under "Told files" (in the archive, or unknown before launch when the archive doesn't hold them) and under "Writes" as copies into that seat's own storage, never into the project. A member's plugin folder also appears under "Also runs" with its hook commands, the same way an agent spec's plugin does, because it loads like any plugin.

A plugin folder's copy is now named by one shared rule used by the schema's clash check, the launch and this view, so the view names exactly where the launch copies it (`my review` becomes `my_review`). One limit: on Linux and macOS, a folder whose name contains a literal backslash is now named by its last backslash-separated part at launch too, as the schema's clash check already did; a seat with an older copy gets a fresh one under the new name.

Cost: source +33/-3 (3 files), tests +22 (1 file). No new command, view, refusal or error code.
2026-10-21 21:25:26 -07:00
Mike Schwarz 26eb126bda fix(daemon): a seat whose storage a link refuses starts without its skills and additions (#1744)
When a seat's storage under the OpenRig home has a link on the way (for example, a moved `state/seats` that left a symlink behind), `rig up` now starts that seat instead of failing it. A one-seat rig used to fail outright. The seat starts without what it couldn't place, and the result names each loss: `seat_skills_not_delivered: <skill ids>: …; the seat starts without these selected skills`, and one `seat_launch_addition_not_delivered` line per agent, prompt file or plugin folder it asked for. Nothing is written through the link, and nothing falls back into the person's project. A seat started through the startup path already behaved this way.

Only that linked-storage refusal changes. Any other skill reconciliation error still fails the seat, and so do a missing or invalid addition source and a storage refusal whose storage isn't itself linked.

Cost: source +17/-5 (1 file), tests +64/-4 (1 file). No new refusal or error code; two warning codes name the losses.
2026-10-21 21:24:15 -07:00
Mike Schwarz b33f20c6bc fix(edge): say when an edge change moves who work routes to (#1746)
`rig edge add` and `rig edge remove` on a `delegates_to` edge now say when the change moves routing. That covers two cases: which seat a seat's work escalates to (its chosen orchestrator), and whether the rig still has a single representative, which is where a person's Slack message in the rig's mapped channel goes unless that channel names an inbound seat. Before, the receipt named only the edge and its rollback, so an agent could change where work routes without being told.

The change reads the queue's own resolvers before and after the write, in the same transaction and read-only, and reports any difference. The JSON receipt gains an optional `routing` field, and the plain output prints one `routing:` line per move. A change that moves nothing, and every other edge kind, says nothing extra. Nothing is refused or confirmed, and the resolvers themselves are unchanged.

Cost: source +53/-1 (daemon +42/-1, CLI +11), tests +84, docs +9. No new refusal, error code or flag.

Refs #1203
2026-10-21 21:01:52 -07:00
Mike Schwarz 4fce5e37ad fix(daemon): a seat whose launch additions record can't be read still launches (#1742)
A Claude seat whose `launch-additions.json` was hand-edited, or written by another OpenRig version, now still launches, restores and hands over. Before, a record that wasn't the shape OpenRig writes (for example `{}`) made that one seat's launch fail with a TypeError. Now it launches without its additions and says why: `seat_launch_addition_refused: … isn't a launch additions record OpenRig can read (not JSON, or not the shape it writes) …; launched without it`, the same way a missing addition already behaves.

`readManifest` accepts only a record in the shape OpenRig writes; every launch, restore, handover and seat-view reader goes through it. Any other record in the seat's storage, including unparseable JSON that used to be skipped silently, is named in the existing refused list. A correct record behaves as before.

Cost: source +23/-3 (1 file), tests +26/-8 (1 file). No new refusal, error code or flag.
2026-10-21 20:51:23 -07:00
Mike Schwarz daa23d31ee docs: say where a team seat's skills live (#1741)
Since #1008, a team seat's skills live in its own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, not in the project's `.claude/skills` or `.agents/skills`. Several pages still taught the old folders, so an agent orienting from them looked in the wrong place.

Now they say where each runtime's skills go: Claude's into the seat's generated plugin (`plugin/skills/<id>`, shown as `openrig:<id>`), Codex's into `skills/<id>` beside its catalog, and the project folders only for a seat without that storage. `rig skill loadout --seat <session|self>` is named as the way to see a seat's own skills. Changed: the agent spec's skills, plugins and discovery lines, two architecture pages, the test-layers note, the shipped `forming-an-openrig-mental-model` skill (with its digest pin), and the public capability map. Stale source citations in the adapters page are refreshed, and three code comments that named a nonexistent `seat_material: seat` key now describe the seat's own skill folder.

Cost: text +47/-29 (docs +25/-14, shipped skill +13/-8, capability map +5/-3, digest +1/-1, code comments +3/-3). No behaviour change.

Refs #1008
2026-10-21 20:43:18 -07:00
Wu Shuwen 526a9ed4f9 fix(cli): retain daemon child on indeterminate listener probes (#1716)
`rig daemon start` no longer kills a daemon whose own endpoint answers just because its Tailscale listener self-check came back indeterminate (for example, ECONNRESET). That was #1714: on a host with Tailscale up, every start stopped a healthy daemon.

Now an indeterminate check on the last retry keeps the daemon running and its start reservation, and the command fails with "listener verification is incomplete", naming the daemon's PID, `rig daemon status`, `daemon-start.lock` and `daemon.log`. A listener that is definitely missing still fails and cleans up, as before.

Known limit: the kept daemon isn't yet a fully managed one. `rig daemon stop` refuses because no daemon state was written, and a new `rig daemon start` refuses while the reservation remains, so restarting needs a manual stop of that PID and the lock archived. #1714 stays open for a start that succeeds with a warning when only a secondary listener is uncertain.

Cost: source +7, tests +33 (2 files). No new refusals or error codes.

Related to #1714
2026-10-21 19:59:37 -07:00
Mike Schwarz 67a59f4ac9 feat: a Claude seat's own agent, prompt file and plugin folders (#1737)
A Claude member in a rig spec can bring its own agent, an appended system prompt file and plugin folders, and that seat (only that seat) launches with them, every time it launches:

    members:
      - id: reviewer
        runtime: claude-code
        agent_file: claude/agents/reviewer.md
        append_system_prompt_file: claude/prompts/house.md
        plugin_dirs: [claude/plugins/review]

- The three keys are Claude-only and resolve relative to the spec. OpenRig copies the files into the seat's own storage, beside its skills plugin, and never into the project.
- Every Claude launch passes the copies: fresh, resume, restore, handover, first-start retry and fork. They go through `--agent openrig:<name>`, `--append-system-prompt-file` and `CLAUDE_CODE_PLUGIN_DIRS`. A seat without additions launches exactly as before.
- The fresh-launch plan, the seat view and `rig skill loadout --seat` list a seat's additions. A missing or colliding one fails that seat by name, and `rig up` prints each failed seat's error.
- Identity proof reads `--agent` and the prompt file. A resume reports whether the conversation started with the current prompt (`system_prompt_pending` or `system_prompt_unknown`).
- `rig fork` copies the source seat's additions into the new seat after it's validated, and says when it carried only part of them. If copying fails partway, the files already written stay in seat storage, and the command reports them.

Native checks ran on an earlier head of this work, not on this one; a native fork is untested.

Cost: source +766/-24, tests +1081/-4, docs +3 (48 files).

Refs #875
2026-10-21 19:28:37 -07:00
Mike SchwarzandNash Aschenbath a4b24ff796 feat(claude): per-seat Advisor models (#1736)
A Claude seat can now name an Advisor model in its spec (member, then profile, then agent defaults). OpenRig stores it with the seat and passes `--advisor` to Claude on fresh launch, resume, fork, restore and handover. It's session-scoped, so it never changes a sibling's shared settings, and Codex and other runtimes never receive it. Both process-argument parsers read `--advisor` as a valued flag, so its value is never mistaken for a session ID.

A seat whose first start failed before the Advisor existed still retries. When the node recorded no Advisor, the retry names none, the spec now resolves one and the agent is unchanged, the retry launches without one and stores none. Any other Advisor difference is still refused, with nothing launched.

This finishes #1163 by @Aschenbath (the Advisor field, its resolution and the launch wiring). On top of it: the narrow retry correction, the migration renumbered to 125 after 124, and three merges of main, keeping main's fork session ID, seat effort, startup layers, member env and per-seat plugin folders beside the Advisor.

Cost: source +150/-25, tests +454/-10, docs +34/-1 (28 files). No new refusals or error codes.

Refs #1163
Refs #1152

Co-authored-by: Nash Aschenbath <[email protected]>
2026-10-21 18:47:32 -07:00
Mike Schwarz a553a0855f refactor(delivery): remove input-box diagnostic reporting (#1738)
Delivery results and queue records still carried before/after input-box diagnostics after the verification decision was made. Remove that reporting and persistence path, including CLI box descriptions, while retaining the opt-in verification setting and all decision-bearing readers. Historical diagnostic explanations are no longer available; existing database columns remain for compatibility.

Update the transport documentation and preserve the setting-toggle test's second-send assertion. Eight files, net 533 lines removed.

Validation: daemon and CLI builds passed; 352 focused cases passed before the documentation/assertion followup, then the affected T42 case passed (218 skipped). Full suite and live opt-in sends were not run.
2026-10-21 18:45:38 -07:00
Mike Schwarzandakushonkamen 98e31a631e feat(daemon): token and percent-of-window thresholds for the context watch (#1735)
`rig watchdog register` takes `--threshold-tokens <n>` and `--threshold-percent <n>` for the context-usage watch, so a seat's continuity action can fire on a token count or on a share of its context window (#1180).

When the watch can't read its sample (unavailable, stale, or no token count), it tells the target once per reason for that occupant, instead of repeating a loud skip. A notice never counts as a threshold fire and never runs a continuity action, so a later fresh sample still fires.

This finishes #1198 by @akushonkamen: the two thresholds, their units and the migration. On top of it:
- the one-time notice, banked only when delivered, so a failed one retries;
- the migration renumbered from 102 to 124, and declared with its sibling watchdog migrations in the test database's curated exclusions;
- the two flags in `cli-reference.md`.

Cost: source +425/-41, tests +288, docs +1/-1 (13 files). No new refusals or error codes.

Refs #1198
Refs #1180

Co-authored-by: akushonkamen <[email protected]>
2026-10-21 18:23:56 -07:00
Mike SchwarzandNash Aschenbath c157b01814 feat(cli): add or remove one typed edge on an existing rig (#1733)
`rig edge add` and `rig edge remove` change one edge on a running rig. Someone who replaced a seat and lost its cross-pod edges (#1193) can put them back without taking the rig down; `rig add` carries only pod-local edges.

- `add` is idempotent: a repeat returns the existing edge. It refuses a launch-dependency cycle (`delegates_to` and `spawned_by`, in launch and restore order) and takes `--plan` and `--json`.
- `remove` takes an exact edge ID within the named rig. A repeat changes nothing and reports `edge_not_found`.
- Stuck-row routing and `rig whoami` see a new edge at once. Restore reads the selected snapshot's edges, so take `rig snapshot` after a repair, as the command says.
- A flat legacy spec now accepts the same five edge kinds as a pod-aware one, so a repaired rig's export still imports.

This finishes #1203 by @Aschenbath (rig names, rig-scoped removal, the request checks, the immediate transaction, the snapshot note, the docs, the #1193 journey and JSON errors when the daemon is down), built on #1034's single add and remove. Their batch file is left out, as our review on #1203 asked.

Cost: source +364/-6, tests +538/-5, docs +60 (15 files). New error codes, on the new command only: `invalid_kind`, `edge_cycle`, `edge_not_found` and `edge_route_missing`. None refuses anything that works today.

Refs #1203
Refs #1193

Co-authored-by: Nash Aschenbath <[email protected]>
2026-10-21 18:07:07 -07:00
Mike SchwarzandPeter Banka 7be9d1d9fc fix(daemon): return the full workflow trail on request, in the default's order (#1732)
A workflow trace can now return the whole trail on request: `GET /api/workflow/:id/trace?full=true` reads every entry, newest first, in the same order as the default read. Every trace response also says `trailCompleteness`: `complete` when nothing was left out, and `recent` when the default's newest 50 cut something.

This finishes #1258 by @psbanka: their `full=true` flag, `continue(instanceId, { fullHistory })` and `trailCompleteness`. On top of it:
- The full read uses the default's query (`closed_at` newest first, then recorded order) with no limit, so the default's 50 are always the full read's first 50. Before, it read in recorded order, which differs when a later entry records an earlier close.
- The default reads 51 rows and returns 50, so `recent` means an entry was actually cut. Before, a 3-entry trail read `recent`.

Cost: source +9/-3 in 2 files, tests +78 in 2 files; the default read fetches one extra row. No new refusals or error codes.

Refs #1257
Refs #1258

Co-authored-by: Peter Banka <[email protected]>
2026-10-21 17:42:02 -07:00
puniservandAdam Dmitruczuk 5681f20c84 feat(skills): keep each seat's team skills in its own storage (#1008)
Team skills now live in each seat's own storage instead of the shared project. A Claude seat gets its skills as a generated plugin handed to Claude through `CLAUDE_CODE_PLUGIN_DIRS`, with no new launch argument; a Codex seat gets its own catalog and router. Storage is keyed by the rig and logical seat, so a seat keeps its selection across continuation, and the skill audit reports each seat's skills with where they came from. An optional, default-off setting (`skills.router_line.enabled`) adds one bootstrap line to the project's guidance.

A seat's storage isn't written through a link: whatever is there is left alone, and OpenRig names the selected skills it couldn't deliver (`seat_storage_refused`, and the seat catalog, router and plugin notes) instead of falling back to the project; a launch whose loadout is required stops there. The Claude plugin path needs Claude Code 2.1.280 or newer; OpenRig doesn't enforce that version.

This builds on #1008 by @puniserv: the skill loadout location (`SkillLoadoutLocation` in `skill-catalog.ts`) and the per-seat audit (`skill-discovery.ts`), with their tests, are their code, and the generated plugin's layout is adapted from their `claude-seat-material.ts`. As agreed on this pull request, it leaves out the `seat_material` spec key and its migration, the extra launch arguments, and moving settings, MCP config or guidance out of the cwd.

Cost: source +1786/-55, tests +2422/-46, docs +2/-1 (48 files). The one new refusal code is `seat_storage_refused`, for a write through a link out of the seat's own storage.

Co-authored-by: Adam Dmitruczuk <[email protected]>
2026-10-21 17:38:52 -07:00
Mike Schwarz 82cb85f163 docs(contributing): use the npm that comes with Node; a distro npm 9 fails a fresh npm ci (#1731)
Someone setting up a clone with a separately packaged npm 9, such as Debian's `npm` package (9.2.0), hits a failed `npm ci`: `ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`.

CONTRIBUTING's setup paragraph now says to use the npm that comes with your Node, which is npm 10 or newer for Node 22 and 24, and shows how to check (`npm --version`). `engines.npm` is unchanged.

Text only: `CONTRIBUTING.md` +3/-1.
2026-10-21 16:59:07 -07:00
Mike Schwarz 5f64645e29 fix(daemon): wait for Claude's input box before a fresh seat's startup text (#1730)
A fresh Claude seat could be sent its first startup text before Claude had drawn its input box, and the text was then left unsent.

Now, before a fresh Claude start's first send (through `rig up`, an added or grown seat, `seat launch --fresh`, a continued fresh start or a managed launch), the startup orchestrator looks at the screen until the input box is drawn, using the startup check's own recognizer (`claudeInputBoxDrawn`, from `composerRegion`): at most 25 looks, 200 ms apart. If no box appears in that time, or the screen can't be read, the text is sent as it is today. That fallback is best effort: it doesn't guarantee the text is never sent early, and it isn't a startup pass. Readiness (`checkReady`, `native-resume-probe.ts`) and its callers are unchanged, and restores and later sends don't get this wait.

After the Enter, on every Claude orchestrator send, restore replay included, a screen with text but no input line is now looked at again within the same bound instead of reading unverified at once. If our exact text then shows staged, only the existing exact-own-text, submit-only retry acts; an empty box reads as submitted; a draft, a permission prompt or anything else stays unverified with no further Enter. There's no repaste.

Limits: handover successors start through their own launch and send path, so neither wait covers them. A fresh Claude start can take up to about 5 seconds longer when no box is drawn, and a send whose screen shows no input line after the Enter up to about 5 seconds more. Nothing is refused. Native launch wasn't run; it remains the acceptance check.

+139/-7 in 5 files: source +35/-5, tests +104/-2 (2.97 test lines per added source line).
2026-10-21 16:50:47 -07:00
Mike Schwarz 7ab6c8c667 fix(refocus): preserve child PATH for failure reports (#1729)
After three consecutive failed refocus attempts, refocus reports the failure to the stream with `rig stream emit`. On a seat whose PATH has no Node, that report couldn't start, so the failure stayed local and the seat's stderr said the report was unconfirmed.

Now the report uses the same child environment refocus's other `rig` calls have had since #1728: the seat's PATH with the running Node's directory appended. `recordRefocusResult` takes it as an optional `env`, defaulting to `process.env`, and refocus is its only caller. When the report is sent, the failure count, the third-failure threshold, the retained issue ID, the occupant key and the deadline are unchanged; the episode still resets after a successful refocus.

The standalone `compose.py` and `trace-to-root.py` scripts are out of scope. Nothing is refused, and there's no new setting. Native Claude hook loading and a live stream write weren't run.

+15/-8 in 3 files: source +3/-3, tests +12/-5 (one existing report test, now also run with an env-node `rig` on a PATH with no Node).
2026-10-21 16:42:27 -07:00
Mike Schwarz ace30cfb20 fix(hooks): run child commands without Node on PATH (#1728)
#1727 let the openrig-core plugin's Claude hooks start without Node on the PATH, but two of the commands they start still ran `node` or `rig` from the PATH: the pre-compaction hook's restore-packet step and refocus's `rig` lookups. On a seat whose PATH has no Node, compaction left only manual-recovery text instead of a restore packet, and refocus fell back without its content, work and configuration lookups.

Now the pre-compaction hook runs its restore-packet generator with the hook's own Node (`process.execPath`) and the same arguments, so the packet and its pending marker are written. Refocus gives its four `rig` calls and its Python trace a PATH with the running Node's directory appended after the seat's own, so a `rig` that starts through `#!/usr/bin/env node` works, and any `rig`, Python or Node the seat's PATH already finds still wins.

Limits: `rig` and Python must still be installed. Refocus's report of repeated failures (`refocus-health.cjs`), and the refocusing and operating-model skill scripts when run directly from a minimal shell, still need Node on the PATH; that's a follow-up. Nothing is refused, and there's no new setting or step. Native Claude hook loading wasn't run.

+43/-10 in 5 files: source and plugin assets +9/-6, tests +33/-3 (3.67 test lines per added source line), regenerated skill digest +1/-1.
2026-10-21 16:17:10 -07:00
Mike Schwarz bc0cde5bf0 fix(claude): run plugin hooks without Node on PATH (#1727)
On a machine where Node isn't on the PATH a Claude seat starts with, the openrig-core plugin's hook commands couldn't start, because each one ran `node` from the PATH. OpenRig's generated settings already pinned Node (#1545); the plugin's own manifest didn't.

Now all 11 of the plugin's Claude hook entry commands start with the daemon's own Node through the existing `OPENRIG_NODE` variable. Fresh launches, managed Claude launches, and handovers of seats with a recorded working directory now pass it. Without it, a command falls back to `node` on the PATH, as before. Refreshing a seat's settings recognizes OpenRig's own relay and status-line entries when someone has repaired them by hand with an unquoted or single-quoted absolute Node path, and replaces them instead of adding a second relay; commands with shell expansion, wrappers or other hooks stay the user's.

Limits: commands the hooks themselves start still need Node on the PATH: the pre-compaction hook's restore-packet step runs `node`, and refocus runs `rig`. Existing seats aren't relaunched or repaired; they pick this up at their next launch. An older daemon with only the new plugin, or the new daemon with an older plugin copy, still needs Node on the PATH. A hand repair written in double quotes isn't recognized, so it stays beside OpenRig's relay and each event is posted twice, as before. A seat whose activity hooks are disabled in its profile, running an older plugin copy on a minimal PATH, loses a single-quoted hand repair at the next refresh until it's relaunched. Nothing is refused, and there's no new setting or step. Native Claude hook loading wasn't run.

+94/-44 in 12 files: source and plugin assets +37/-33, tests +57/-11 (1.54 test lines per added source line).
2026-10-21 15:42:47 -07:00
Mike Schwarz 63c492c852 fix(workflow): record the caller as the agent that projected a step (#1721)
When an agent advances a workflow step (`rig workflow project`, `route`, `resume` or `abort`), the record now names the agent that made the call. Until now it recorded whatever `--actor-session` the command carried, and the commands OpenRig generates for a step filled in the packet's owner, so a step's history could name an agent that never touched it.

The four routes take the caller from the transport identity the CLI already stamps, through the same helper other routes use. The caller's identity supersedes a declared actor; a caller with no identity is recorded under its declared actor, labelled as claimed; a relayed caller is recorded no stronger than the relay carried it. The closing transition, the next packet and the wake intent carry that provenance, with no new field. When a known caller projects a packet another seat owns, `project`'s response says so in one line; nothing is refused and nothing extra goes to the owner. Generated commands no longer name an actor, and `--actor-session` is now optional.

Two results change. A request with neither an identity nor an actor is still a 400, but its body is now the shared `actor_required` error instead of `{"error": "actorSession is required"}`, so a client matching the old string sees a different body. A request with no identity whose declared actor is only whitespace is now refused. A non-blank declared actor without an identity still works.

The recorded agent is who projected the step, not proof of who built, reviewed, tested or approved the work.

Refs #1710.

+233/-65 in 14 files: source +83/-47, tests +136/-8 (a new five-case route-to-store test), docs 8 lines, and the regenerated skill digests.
2026-10-21 14:44:25 -07:00
Mike Schwarz 78c6b9e9f0 fix(slack): retry when reconciliation history is unreadable (#1724)
After an ambiguous Slack post, replay could retain the message when history could not be read, even if Slack was accepting posts. Retry now follows the existing partial-history path and posts once with possible-repeat context. Successful reconciliation and delivery receipts still prevent a fresh post.

The optional repeat note uses the existing renderer and is omitted when it cannot fit. Retry scheduling is unchanged.

Validation: daemon build passed, followed by 28 focused tests (durability, invalid blocks and reconciliation pagination). QA independently ran the unreadable-history replay case and verified disclosure and re-ack without another post. Full Slack suites, live Slack and a failing baseline run were not executed.

+65/-62 in 6 files: source +36/-29, tests +29/-33.
2026-10-21 14:37:31 -07:00
Mike Schwarz 2f001ad044 fix(gateway): a mode waiting for its scheduled end leaves no watchdog record each minute (#1722)
A mode applied with an end time is watched by a job that runs every 60 seconds. While the mode was still waiting to end, each pass wrote a watchdog history row and an event, about 480 of each for an eight-hour mode.

The waiting skip now returns its own reason, `mode_end_not_yet`, which the engine treats as quiet, the same way the quiet-hours release already does with its own reason. A notice still owed after the mode has ended keeps its recorded skip, because an operator may need to see it. The job's last-evaluation record still updates, so this removes the per-minute history rows and events, not every write.

One test runs the real engine and policy: two waiting passes leave no history row and no event, while an owed notice still records one.

Source +6/-1, tests +30/-1.
2026-10-21 13:55:18 -07:00
1204 changed files with 94316 additions and 8148 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
github: mvschwarz
+36 -1
View File
@@ -3,7 +3,8 @@ 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 deny external networking; scenario runtime has no external route.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
on:
push:
@@ -76,6 +77,40 @@ 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,
+4 -3
View File
@@ -290,9 +290,10 @@ 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`, 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
- **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
`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
+2239
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -42,7 +42,9 @@ repository, and we're happy to link to it.
Node `^22 || ^24`, a working `tmux` and `rsync` are required. macOS includes `rsync`; many Linux
installs don't (`sudo apt-get install rsync` on Debian or Ubuntu). `npm test` and `npm run mirror-skills`
use it. Then:
use it. Use the npm that comes with your Node, which is npm 10 or newer (`npm --version` shows which you
have). A separately packaged npm 9, such as Debian or Ubuntu's `npm` package, fails a fresh `npm ci` with
`ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`. Then:
```bash
git clone https://github.com/mvschwarz/openrig.git
+9 -8
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.6.12/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.6.12/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/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.6.12` 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.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:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | 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.4/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.
@@ -226,9 +226,10 @@ user's home; changing `OPENRIG_HOME` alone does not isolate provider configurati
installation. Selected settings resources can still replace overlapping keys.
The collector script lives under `.openrig/`, and the activity-hook relay under
the instance's `state/claude-activity-hooks/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource sets
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
selected MCP resources configure those external services. Built-in bootstrap
change that settings file and `.mcp.json`. The shared settings resource enables
Exa/Context7 MCP entries and leaves existing `permissions.defaultMode` and permission
rules unchanged. Managed launch flags select the session mode. Selected MCP resources
configure those external services. Built-in bootstrap
no longer writes a command allowlist to `~/.claude/settings.json` or removes
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
To keep your own status line alongside the collector, see
+6
View File
@@ -6,6 +6,12 @@ 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
[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.
## How to help
1. Pick an item and comment on its issue to say you're working on it and coordinate with others. A maintainer can assign
+9 -5
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.22.1
ARG NODE_VERSION=22.23.4
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH}" in \
@@ -33,10 +33,14 @@ 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; \
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_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; \
node --version; npm --version
# --- layer 3: the OpenRig package from the LOCAL pack tarball, never the npm registry ---
@@ -88,6 +88,39 @@ Readiness has its own attention codes (`runtime-adapter.ts:74–87`):
When `checkReady` reports one, the readiness wait returns at once and startup
ends `attention_required`.
### Evidence for a failed Claude resume
Previously, a failed Claude launch recorded its error without the probe code or
the captured input's identity, and a refused strict full-restore attention clear
had no corresponding refusal event. Probe-backed Claude launch failures now
carry an optional `probe` in `HarnessLaunchResult` and `node.startup_failed`.
Strict full-restore refusals emit `seat.attention_clear_refused`; a
`pane_not_usable` refusal includes that probe and the restore attempt ID.
A missing strict reconciler emits `strict_reconciler_unavailable` without
inventing an attempt ID or probe evidence, and still refuses the clear.
`native-resume-probe-evidence.ts` records the status, code, a closed-set login
refusal and row kind when matched, and the exact capture's line count, byte
count and SHA-256. Its new fields contain no captured pane text or free-form
probe detail. Existing attention evidence and recovery decisions keep their
previous behavior. MCP approval detection still takes precedence over the
login-refusal check.
Pane dimensions are optional, observed after the capture. Their read has a
250 ms deadline so a hung `listPanes` cannot indefinitely delay the failure
result or its event. On timeout, error, ambiguous pane identity or invalid
dimensions, the unavailable dimensions are `null`; late results are ignored.
The deadline bounds this diagnostic wait, not the underlying tmux command.
`native-resume-probe-evidence.test.ts` covers unavailable and late geometry;
`startup-orchestrator.test.ts` and `restore-orchestrator.test.ts` exercise the
real adapter/orchestrator and SQLite event paths with available, failing and
never-settling geometry reads. `seat-attention-reconciler.test.ts` checks that
a missing strict reconciler records its refusal without changing restore
history or sending input. These use synthetic terminal captures, not a
live Claude session. The stub scenario runtime cannot draw Claude's native
login-refusal UI, and its scenario runner does not bind the restore action.
## 2. The runtime adapters
**5** classes implement `RuntimeAdapter`
@@ -205,8 +238,12 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`<cwd>/CLAUDE.md` by default (`:194`, `:501`; default at
`domain/managed-blocks.ts:17`, and the rig may select `CLAUDE.local.md`
instead with `managed_blocks: { claude-code: <file> }`,
`managed-blocks.ts:15`); `skill_install` → `<cwd>/.claude/skills/<name>/`
(`:200`, `:582`); subagents → `.claude/agents`, plugins →
`managed-blocks.ts:15`); `skill_install` and skill resources → the seat's
skill root, `binding.seatSkillsRoot`, which is the `skills/` folder of the
seat's generated plugin in its own storage
(`domain/startup-orchestrator.ts:353–369`), or
`<cwd>/.claude/skills/<name>/` for a seat without one (`:215–216`,
`:849`); subagents → `.claude/agents`, plugins →
`.claude/plugins/<id>` (only plugins that apply to Claude), runtime
resources → `.claude/extensions/<id>` (`:584–586`); settings fragments
merged into `.claude/settings.local.json` (`:594`) and MCP fragments into
@@ -244,14 +281,45 @@ 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`)
- `readonly runtime = "codex"` (`:66`).
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
(`:309`, `:576`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:315`);
skills resolve under `.agents/skills/<id>` (`:664`); plugins go to
`.codex/plugins/<id>` (`:667`).
(`:309`, `:576`); `skill_install` and skill resources → the seat's skill
root, `binding.seatSkillsRoot`, which is the `skills/` folder beside the
seat's catalog in its own storage
(`domain/startup-orchestrator.ts:360–369`), or
`<cwd>/.agents/skills/<name>/` for a seat without one (`:563–564`,
`:1060`); plugins go to `.codex/plugins/<id>` (`:1063`).
- **Launches/resumes**: fresh = `codex[ --no-daemon]<posture>[<network>] -C
<cwd> …` (`:442`), then capture a fresh thread id (`:460`); resume is built
by `buildCodexResumeCore` (`:441`) as `codex<daemon><posture> resume
@@ -316,6 +384,20 @@ 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,6 +119,37 @@ 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
@@ -68,9 +68,10 @@ system-level invariants.
14. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
`types.ts:475`): `resumed`, `rebuilt`, `fresh-primed`,
`awaiting-decision`, `attention_required`, `failed`, plus
`operator_recovered` (set only by later reconciliation) and `fresh`
(retained only for the legacy continuity-restoring skip path). `rebuilt` =
new process assembled from artifacts.
`operator_recovered` (set only by later reconciliation), `fresh`
(retained only for the legacy continuity-restoring skip path) and
`left_attached` (a seat joined with `rig attach` as an external CLI, which
restore leaves as it is). `rebuilt` = new process assembled from artifacts.
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
the blank session rolled back and no session running
(`restore-orchestrator.ts:1055`). No automatic fresh fallback. Fresh launch
@@ -146,9 +146,21 @@ that gateway side too. For the pane side, read:
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
the stored result again.
- `claimedAt`: when the destination claimed the row. Closing doesn't clear it.
- `pickup`: the derived pickup receipt (`unclaimed`, `working`,
- `pickup`: the derived pickup receipt (`unclaimed`, `working`, `needs-input`,
`stalled-after-claim`, `parked`, `terminal`), computed at read time.
A claimed item whose owner's activity reports outstanding input reads
`needs-input`, with the reason in `pickup.evidence`. That covers an item in
progress and a `pending` item that kept its claim, after an update back to
`pending` or a reopen. A Codex permission request the daemon couldn't confirm on
screen reads `owner may need input (not verified: permission_request_unverified)`.
The input signal can outlast the prompt it reported, so it never pauses
recovery. When the claim would otherwise read `stalled-after-claim`, the receipt
keeps that evidence in `pickup.stall`, and the stuck sweep's finding, its
backstop and manual dispositions behave exactly as for `stalled-after-claim`.
Queue reads, the pickup view and the execution view share this receipt
(`queue-pickup.ts`). Unclaimed, blocked and terminal items keep their receipts.
A claim isn't proof that the body was read or that the terminal render was
verified, and closing a task isn't a new delivery receipt: a claimed task that
is later closed still reads `claimed` when its stored result was unconfirmed
@@ -166,6 +178,60 @@ 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
@@ -417,7 +483,9 @@ 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. Without the
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
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
+1 -1
View File
@@ -56,7 +56,7 @@ six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
### The stack
```text
CLI (87 top-level commands) / TUI / web UI / MCP (23 tools)
CLI (87 top-level commands) / TUI / web UI / MCP (24 tools)
|
v
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
@@ -37,8 +37,9 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
result itself, `RestoreNodeResult.status` (`types.ts:475`), has the same
values without `n-a`: **8**
values without `n-a`, plus `left_attached`: **9**
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
`left_attached` isn't a `NodeRestoreOutcome`; the inventory projects it as `n-a`.
Where `restore-orchestrator.ts` sets each one:
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1030`); the
status is returned only by `finishJoinedResume` (defined `:1320`, called
@@ -61,6 +62,10 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`:1292`, `:1300`).
- `operator_recovered` — only from later reconciliation,
`reconcileNodeRuntimeTruth` (`:1590`, event at `:1727`).
- `left_attached` — a seat joined with `rig attach` as an external CLI,
returned first in `restoreNodeWithCompensation`: nothing is launched and
its binding and claim stay as they were. It doesn't count against the
rollup, and its forecast in `restore-plan-preview.ts` is `leave-attached`.
- **RestoreRigResult** (`types.ts:444`) — the rig-level rollup, **4** values
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`fully_restored`, `partially_restored`, `failed`, `not_attempted`.
@@ -68,10 +73,26 @@ 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
**7** optional fields, kept optional so older snapshots still load
**8** 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?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
`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.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
@@ -384,6 +405,20 @@ 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
+10 -9
View File
@@ -6,7 +6,7 @@ topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
applies-when: |
Need to know how the daemon-backed Mission Control surface works — the
seven views, the eight write verbs, the action audit table, the
seven views, the nine write verbs, the action audit table, the
bearer-token middleware, or how queue observability maps to the
coordination primitive's sources.
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
@@ -20,7 +20,7 @@ last-updated: 2026-10-08
Mission Control is the daemon's queue-observability and operator-action
surface, mounted at `/api/mission-control` over the coordination primitive's
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
seven views, eight verbs, a recent-ships cap of 10
seven views, nine verbs, a recent-ships cap of 10
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
table. In the web UI the old `/mission-control` page now redirects to
`/for-you`, and the For You feed carries the verb actions (§6).
@@ -85,14 +85,14 @@ There is no filesystem fallback. When no `StreamStore` is wired,
> `rig` or `rig tui` is called the "mission-control TUI" in source
> (`packages/tui/README.md:1`), but it does not call these routes.
## 2. The eight verbs (the write contract)
## 2. The nine verbs (the write contract)
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–26`) is the verb
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–29`) is the verb
list. The verbs execute through `MissionControlWriteContract.act`
(`mission-control-write-contract.ts:131`); the closure mapping is
`verbToClosure` (`:509`).
Verbs: **8** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
| Verb | Effect |
|---|---|
@@ -103,7 +103,8 @@ Verbs: **8** — `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` | 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`) |
| `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`) |
| `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`
and `handoff` require `destinationSession`, and `resolve` requires a non-empty
@@ -113,11 +114,11 @@ 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 not parked on
a human seat. An unknown item returns 404 `qitem_not_found`
`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`
(`routes/mission-control.ts:240–251`, `:330–341`).
Each verb is one atomic daemon transaction: the queue mutation via
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
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2371`,
which keeps the hot-potato closure validation — see
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
@@ -123,11 +123,15 @@ is refused; the other content checks are advisory.
`packages/daemon/src/domain/skill-catalog.ts` resolves a seat's skill loadout from the
catalog at `skills.root` (`catalog.yaml`, schema `openrig.skill-catalog/v1`) and the system,
topology and project selections. It projects skills into `<cwd>/.claude/skills` or
`<cwd>/.agents/skills` and tracks what it owns in
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. Each projection reports `current`, `missing`,
`shadowed`, `stale` or `conflicting`. `rig skill loadout --runtime <runtime>` inspects the
composed projection and reconciles it only with `--apply`; `rig skill audit` is a separate
topology and project selections. For a working directory it projects skills into
`<cwd>/.claude/skills` or `<cwd>/.agents/skills` and tracks what it owns in
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. For a Claude or Codex team seat it takes a
`location` instead: the seat's own storage outside the project, with its `skill-loadout.json`
there, and for Claude the `openrig:<id>` names its generated plugin gives. Each projection
reports `current`, `missing`, `shadowed`, `stale` or `conflicting`.
`rig skill loadout --runtime <runtime>` inspects the composed projection for a working
directory, `rig skill loadout --seat <session|self>` does the same for one seat's own skills,
and both reconcile only with `--apply`; `rig skill audit` is a separate
read-only provenance and freshness audit. Launch applies the selected loadout per seat.
For a Claude Code or Codex seat, the profile resolver also adds each selected plugin's skills to
@@ -280,7 +284,9 @@ with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
that is writable, otherwise under `compaction/preparation/` in the OpenRig home.
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
final send checks, after rechecking the attempt and occupant. An already-complete final map
remains compatible. Only then is `/compact` sent, with the final map path appended to the
@@ -33,12 +33,12 @@ blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
substitution.
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
registers **23** tools, all named `rig_*`
registers **24** tools, all named `rig_*`
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
(`:402`), and the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
and `_update`. No `rigged_*` name other than the tmux keys below remains in product
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
source: **0** hits
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
@@ -277,21 +277,21 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
after Enter must show the box empty, with the pane process
(`#{pane_pid}`) and the seat's binding unchanged.
The result's `inputBox: { seat, before, after, sameProcess }` says which
reading failed. An empty box reads `queued` instead of `empty` where the
frames say the message may wait behind a turn: Codex's pending-input rows
or a row under its running-turn status, or a running turn in the frame
read before Enter (Codex's `• Working` row; for Claude Code,
`esc to interrupt` or a live status row above the box, including above a
box of several rows). The verdict uses these readings: verified needs
`empty`, so for a seat that was idle before the paste they are the only
check that rejects a message queued behind a turn that started after the
pre-paste capture. An audited prompt answer (step 4) reads no box: with
the setting on its `inputBox` reads `unchecked` for `seat`, `before` and
`after`, `sameProcess` is `false` (it stays a boolean), and the send reads
unconfirmed, whatever the default matcher would say.
It reports only: the seat is read from the capture `--verify` already
takes, and none of the extra reads decides whether Enter is sent. Each has
These readings stay internal to the send; the result carries only the
verdict (`verified`, `outcome`). An empty box reads `queued` instead of
`empty` where the frames say the message may wait behind a turn: Codex's
pending-input rows or a row under its running-turn status, or a running
turn in the frame read before Enter (Codex's `• Working` row; for Claude
Code, `esc to interrupt` or a live status row above the box, including
above a box of several rows). The verdict uses these readings: verified
needs `empty`, so for a seat that was idle before the paste they are the
only check that rejects a message queued behind a turn that started after
the pre-paste capture. An audited prompt answer (step 4) reads no box: with
the setting on it stays unconfirmed, whatever the default matcher would
say.
The readings decide the verification verdict, not whether Enter is sent:
the seat is read from the capture `--verify` already takes, and none of
the extra reads decides whether Enter is sent. Each has
a 500 ms deadline, which is a budget, not a guarantee: the pre-Enter read
comes after the existing 200 ms wait and can add about 500 ms before
Enter, and the post-Enter process read can add about another 500 ms
@@ -462,6 +462,40 @@ 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,3 +112,7 @@ 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.
+144 -45
View File
@@ -9,23 +9,23 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: 92a2b8f7157661716a9c79de742cd82a96d1e6f7
last-updated: 2026-10-09
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
last-updated: 2026-10-26
---
# CLI Reference — Registered Commands and Options
Verified against source commit `92a2b8f7157661716a9c79de742cd82a96d1e6f7`.
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
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 **87 top-level registrations**, **356 registered command objects**
There are **90 top-level registrations**, **410 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,084 explicitly registered option objects**, including the root version
**1,304 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 source counts, not a claim about a deployed release.
These are counts for that source snapshot, not a claim about a deployed release.
## Reading this reference
@@ -143,7 +143,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>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
| `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 stop` | — | — |
| `rig daemon status` | — | — |
| `rig daemon logs` | — | `--follow` |
@@ -188,10 +188,13 @@ 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>` |
| `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 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` |
| `rig gateway human quiet clear <entityId>` | — | `--actor <actor>`<br>`--json` |
| `rig gateway human quiet show <entityId>` | — | `--json` |
| `rig gateway human mode` | — | — |
| `rig gateway human mode save <entityId> <name>` | — | `--availability <value>`<br>`--expected-back <when>`<br>`--decision-room <words>`<br>`--exclude <item...>`<br>`--quiet <rule>`<br>`--tz <zone>`<br>`--account <where>`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode saved <entityId> [name]` | — | `--json` |
@@ -202,7 +205,27 @@ 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
@@ -288,7 +311,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig bundle` | — | — |
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-login-files`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
| `rig bundle configurations <spec>` | — | `--json` |
| `rig bundle inspect <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--json` |
| `rig bundle install <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--target <root>`<br>`--cwd <path>`<br>`--skip-version-check`<br>`--force`<br>`--json` |
@@ -299,13 +322,13 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--env <KEY=VALUE>` (repeatable)<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--env <KEY=VALUE>` (repeatable)<br>`--name <rig>`<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
### down
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--json`<br>`--host <id>` |
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--yes`<br>`--json`<br>`--host <id>` |
### archive
@@ -331,12 +354,31 @@ 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 |
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
### roster
| Invocation | Aliases | Declared options |
@@ -351,7 +393,20 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>` |
| `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 token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
| `rig mcp token revoke <name>` | — | — |
### acp
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig acp` | — | — |
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
### agent
@@ -380,7 +435,7 @@ the sections that were not read are `null`.
| 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>`--reason <text>`<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>`--type-without-checking`<br>`--reason <text>`<br>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
### stream
@@ -392,24 +447,29 @@ the sections that were not read are `null`.
| `rig stream watch` | — | `--json` |
| `rig stream show <streamItemId>` | — | `--json` |
| `rig stream archive <streamItemId>` | — | `--json` |
| `rig stream subscribe <readerSession>` | — | `--tags <tags>`<br>`--type <hintType>`<br>`--actor <session>`<br>`--json` |
| `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
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig queue` | — | — |
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `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` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `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` |
@@ -431,10 +491,11 @@ the sections that were not read are `null`.
| `rig slack verify` | — | `--json` |
| `rig slack channel-map` | — | — |
| `rig slack channel-map list` | — | `--json` |
| `rig slack channel-map set <match> <channel>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack channel-map set <match> <channel>` | — | `--inbound <seat>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `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` | — | — |
@@ -448,8 +509,10 @@ the sections that were not read are `null`.
| `rig project experimental disable` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
| `rig project experimental status` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
| `rig project experimental capture` | — | `--config <file>` **required**<br>`--input <file>` **required**<br>`--output <file>` **required**<br>`--json` |
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
| `rig project decisions` | — | — |
| `rig project decisions status` | — | `--json` |
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
| `rig project shadow-status` | — | — |
| `rig project shadow-drain` | — | `--actor <name>` |
| `rig project shadow-stop` | — | `--actor <name>` |
@@ -475,20 +538,24 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig terminal` | — | — |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
See [named terminal sessions](../reference/terminal-sessions.md) to select a
Herdr session per request, including session-bound previews and window reuse.
### watchdog
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig watchdog` | — | — |
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--threshold-tokens <n>`<br>`--threshold-percent <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
| `rig watchdog list` | — | `-a, --all`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig watchdog show <jobId>` | — | `--json` |
| `rig watchdog status <jobId>` | — | `--json` |
| `rig watchdog stop <jobId>` | — | `--reason <text>`<br>`--json` |
| `rig watchdog update <jobId>` | — | `--interval-seconds <n>`<br>`--message <text>`<br>`--spec <path>`<br>`--json` |
### workflow
@@ -501,7 +568,7 @@ the sections that were not read are `null`.
| `rig workflow operation <key>` | — | `--json` |
| `rig workflow instantiate-lifecycle <missionPath>` | — | `--operation-key <key>` **required**<br>`--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--json` |
| `rig workflow instantiate <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` **required**<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
| `rig workflow list` | — | `--status <s>`<br>`--json` |
| `rig workflow specs` | — | `--json` |
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
@@ -510,16 +577,16 @@ the sections that were not read are `null`.
| `rig workflow continue <instanceId>` | — | `--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow watch <instanceId>` | — | `--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` **required**<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` **required**<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` **required**<br>`--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--json` |
| `rig workflow status` | — | `--json` |
### capture
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--host <id>`<br>`--json` |
| `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` |
### broadcast
@@ -605,7 +672,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig skill` | — | — |
| `rig skill loadout` | — | `--runtime <runtime>` **required**<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
| `rig skill loadout` | — | `--runtime <runtime>` (folder mode; **required** there)<br>`--seat <session>` (seat mode; `self` is this seat)<br>`--wait-for-idle <ms>`<br>`--resend`<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
| `rig skill audit` | — | `--json` |
### agent-image
@@ -623,11 +690,18 @@ Only Claude and Codex tenures can be woken.
| `rig agent-image prune` | — | `--dry-run`<br>`--force`<br>`--json` |
| `rig agent-image sync` | — | `--json` |
### community-harness
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig community-harness` | — | — |
| `rig community-harness list` | — | `--json` |
### fork
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig fork <source-session>` | — | `--rig <rig-id>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
| `rig fork <source-session>` | — | `--rig <rig>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
### workspace
@@ -646,7 +720,7 @@ Only Claude and Codex tenures can be woken.
| `rig mode show` | — | `--json` |
| `rig mode effective` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>`<br>`--json` |
| `rig mode cite` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>` |
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--json` |
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--category <category>`<br>`--json` |
| `rig mode defaults` | — | `--json` |
### policy
@@ -668,7 +742,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose` |
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose`<br>`--all-peers` |
### config
@@ -700,10 +774,10 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig auth` | — | — |
| `rig auth status` | — | `--runtime <runtime>` |
| `rig auth list` | — | `--runtime <runtime>` |
| `rig auth status` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth list` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth save <profile>` | — | `--runtime <runtime>` |
| `rig auth switch <profile>` | — | `--runtime <runtime>` |
| `rig auth switch <profile>` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth validate <profile>` | — | `--runtime <runtime>` |
| `rig auth seats` | — | — |
| `rig auth seats list` | — | `--runtime <runtime>` |
@@ -716,12 +790,14 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig provider` | — | — |
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>` |
| `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 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>` **required**<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
| `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 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` |
### usage
@@ -729,6 +805,7 @@ Only Claude and Codex tenures can be woken.
|---|---|---|
| `rig usage` | — | — |
| `rig usage top` | — | `--window <duration>`<br>`--top <n>`<br>`--json` |
| `rig usage cost` | — | `--seat <session>`<br>`--json` |
| `rig usage series` | — | `--seat <session>`<br>`--lane <lane>`<br>`--since <iso>`<br>`--until <iso>`<br>`--limit <n>`<br>`--json` |
### telemetry
@@ -785,6 +862,20 @@ Only Claude and Codex tenures can be woken.
|---|---|---|
| `rig grow <rig-id> <members...>` | — | `--pod <pod>`<br>`--new-pod <pod>`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--json` |
### edge
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig edge` | — | — |
| `rig edge add <rig> [from] [to]` | — | `--kind <kind>`<br>`--plan`<br>`--json` |
| `rig edge remove <rig> <edge-id>` | — | `--plan`<br>`--json` |
`add` requires `from`, `to` and `--kind`, and is idempotent: a repeat reports the
existing edge and writes nothing. `remove` deletes the edge with that ID; a repeat changes nothing and
returns `edge_not_found` (exit 1). See
[changing edges on an existing rig](../reference/rig-spec.md#changing-edges-on-an-existing-rig)
for input and receipt examples, rollback, and snapshot behavior.
### reconcile-session
| Invocation | Aliases | Declared options |
@@ -840,7 +931,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>` |
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>`<br>`--router-line <answer>` |
### restore-check
@@ -863,6 +954,8 @@ Only Claude and Codex tenures can be woken.
|---|---|---|
| `rig compact-plan` | — | `--json`<br>`--rig <name>`<br>`--refresh`<br>`--threshold-tokens <n>`<br>`--threshold-percent <0-100>` |
- `--threshold-tokens` and `--threshold-percent` set the plan's own candidate thresholds (the percentage applies when a seat's context window size is missing). They are not the automatic compaction setting: the output labels them as the plan's own and shows `policies.claude_compaction` (`enabled`, `threshold_percent`) separately, for comparison only, or says it couldn't be read.
### compact
| Invocation | Aliases | Declared options |
@@ -880,6 +973,7 @@ Only Claude and Codex tenures can be woken.
| 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` |
@@ -887,10 +981,15 @@ Only Claude and Codex tenures can be woken.
| `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-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<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-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>` **required**<br>`--stop`<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 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` |
@@ -973,6 +1072,6 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig proof` | — | `--workspace <path>` |
| `rig proof show [scope]` | — | `--json` |
| `rig proof judge <scope-item>` | — | `--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
| `rig proof show [scope]` | — | `--project <id>`<br>`--json` |
| `rig proof judge <scope-item>` | — | `--project <id>`<br>`--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
| `rig proof add <slice-path>` | — | `--mission <name>`<br>`--artifact-type <type>` **required**<br>`--verdict <verdict>` **required**<br>`--candidate-sha <sha>` **required**<br>`--money-evidence <line>` **required**<br>`--slice-id <dot-id>`<br>`--file <path>`<br>`--body <text>`<br>`--name <filename>`<br>`--replace`<br>`--evidences <refs>`<br>`--self-check <text>`<br>`--media <refs>`<br>`--json` |
+66 -29
View File
@@ -88,6 +88,11 @@ 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.
@@ -104,7 +109,17 @@ 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. |
| `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. |
| `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)).
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
@@ -143,8 +158,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`, `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/` |
| 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/` |
| 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` |
@@ -175,10 +190,12 @@ 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. 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.
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.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
@@ -188,10 +205,13 @@ 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` (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
- 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
`--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
@@ -258,8 +278,8 @@ 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`, `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 into `<cwd>/.claude/skills/`. | A stub green says nothing about provider-specific projection (the class behind #159). |
| 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. |
| 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. |
@@ -279,13 +299,17 @@ The most useful next pieces of harness work, roughly in dependency order:
### Library status
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:
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:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
@@ -294,11 +318,10 @@ other ten scenarios are authored but not yet runnable as meaningful checks:
| `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` | `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 |
| `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 |
| `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
@@ -324,6 +347,19 @@ above remain pending: pane rendering does not prove input consumption, durable
stream replay does not prove live subscription, and this test does not exercise
native providers, restore or policy changes.
`packages/daemon/test/rig-edge-cli.integration.test.ts` uses the same private
scenario daemon and tmux helpers for edge repair. It removes a stub seat, re-adds
its logical ID with the terminal runtime, then restores its cross-pod edges with one
`rig edge add` per edge. It checks repeat IDs and edge counts, refusal without a
write, unchanged sibling node/binding identities and pane PIDs, export,
snapshot capture and exact rollback. It models the cross-runtime replacement
workaround without invoking Claude Code or Codex. The YAML runner has no bound
edge/remove/add verbs, so this journey drives the real CLI directly:
```sh
npm test -w @openrig/daemon -- test/rig-edge-cli.integration.test.ts
```
## If your change touches an artery, add or extend a scenario (or say why not)
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
@@ -428,8 +464,12 @@ 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`. Everything else
has none.
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.
Before you start, two honest constraints:
@@ -552,9 +592,6 @@ 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.
+9 -1
View File
@@ -342,13 +342,21 @@ 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`.
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`;
> `components/KernelStatusCard.tsx`; `components/RigStatusCard.tsx`.
## 5. Cross-cutting properties
+355
View File
@@ -0,0 +1,355 @@
# Handing work between OpenRig and A2A agents
An OpenRig seat can give a queue row's task to an outside A2A agent. In the other direction, an A2A server can
front a seat's queue and return its answer as an artifact. These are compositions of the
[official Python SDK](https://github.com/a2aproject/a2a-python) and existing `rig queue` commands, not a
`rig a2a` command or a daemon endpoint. The scripts below are examples you run and own.
OpenRig does not configure the outside agent's permissions, model or credentials. Choose a peer and task content
you are authorized to share. The peer's result is its report, not acceptance of the work by your team.
## Before either recipe
Use a working OpenRig installation and an existing seat. `rig whoami --json` identifies your seat;
`rig ps --nodes --rig <rig>` shows the receiving seat's canonical session name. If you need a team first, use
[getting started](getting-started.md). Keep the daemon and provider environment you already use.
In a scratch folder, make a Python environment (Python 3.10 or newer, with `venv` and `pip` available):
```sh
python3 -m venv .venv
.venv/bin/python -m pip install 'a2a-sdk[http-server]==1.1.0' httpx uvicorn
```
The examples pin the SDK version used in the pipe trials; they are not a recommendation to downgrade another
application. The official SDK 1.1.0 paths were exercised in both directions. Other 1.x versions were not tested.
Recheck the API and cancellation behavior when changing versions.
Pydantic AI 2.60.0 with FastA2A 2.1.1 did not interoperate with these SDK 1.1.0 recipes in the tested setup:
FastA2A's inbound typed client returned `result={}` even though the SDK front, with 0.3 compatibility enabled,
sent task, artifact and completed records in its raw SSE stream. Outbound, the official SDK raised a protobuf
parse error at the FastA2A task's `status.timestamp`; the full wire timestamp was not retained. When mixing
frameworks, compare the raw stream's task IDs, artifact and final status with the typed client's output.
An HTTP 200 or an empty typed result is not proof that the task succeeded. Retain the evidence before retrying.
Save this helper as `queue_io.py`:
```python
import asyncio
import json
async def rig(*args):
child = await asyncio.create_subprocess_exec(
'rig', *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, err = await child.communicate()
if child.returncode:
raise RuntimeError(f'rig {args[:2]} exited {child.returncode}: {err.decode()} {out.decode()}')
return json.loads(out)
```
It inherits the caller's OpenRig environment. Run queue operations in the intended seat's environment; set
`OPENRIG_URL` only when deliberately selecting another daemon. A timed-out write is uncertain: read its row and
transitions before trying again. Do not silently send the task a second time.
## Outbound: a queue task goes to an A2A peer
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.
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.
Stop the local wait with Ctrl-C or a caller-owned overall deadline; this does not confirm cancellation, so keep the task/context IDs and cancel through the peer when it is reachable.
```python
import asyncio
import json
import os
import uuid
from pathlib import Path
import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import new_text_message
from a2a.types import Role, SendMessageRequest
from google.protobuf.json_format import MessageToDict
from queue_io import rig
async def main():
row_id = os.environ.get('A2A_ROW')
text = os.environ.get('A2A_TEXT')
if row_id:
row = await rig('queue', 'show', row_id, '--full', '--json')
identity = await rig('whoami', '--json')
if row['destinationSession'] != identity['identity']['sessionName']:
raise RuntimeError('Choose a row assigned to this seat.')
if row['state'] == 'pending':
await rig('queue', 'claim', row_id, '--json')
row = await rig('queue', 'show', row_id, '--full', '--json')
if row['state'] != 'in-progress':
raise RuntimeError('The row is not in progress; inspect it before sending.')
if text is None:
text = row['body']
if text is None:
raise RuntimeError('Set A2A_TEXT or A2A_ROW.')
message = new_text_message(text, role=Role.ROLE_USER)
if os.environ.get('A2A_CONTEXT'):
message.context_id = os.environ['A2A_CONTEXT']
if os.environ.get('A2A_TASK'):
message.task_id = os.environ['A2A_TASK']
log_path = Path(f'a2a-{uuid.uuid4()}.jsonl').resolve()
state, bound = 'UNKNOWN', False
with log_path.open('x') as log:
async with httpx.AsyncClient() as http:
card = await A2ACardResolver(httpx_client=http, base_url=os.environ['A2A_URL']).get_agent_card()
log.write(json.dumps({'card': MessageToDict(card), 'message': MessageToDict(message)}) + '\n')
log.flush()
client = await create_client(agent=card, client_config=ClientConfig(
streaming=True, httpx_client=httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None))))
try:
async for chunk in client.send_message(SendMessageRequest(message=message)):
event = MessageToDict(chunk) # SDK 1.1.0 yields StreamResponse, not a tuple.
log.write(json.dumps(event) + '\n')
log.flush()
print(json.dumps(event), flush=True)
task = event.get('task')
if task and row_id and not bound:
await rig('queue', 'update', row_id, '--note',
f"A2A peer={card.name} task={task['id']} context={task['contextId']} log={log_path}", '--json')
bound = True
status = (task or event.get('statusUpdate') or {}).get('status', {})
state = status.get('state', state)
finally:
await client.close()
if row_id:
await rig('queue', 'update', row_id, '--note', f'A2A reported {state}; full result/events: {log_path}', '--json')
print(f'Reported state: {state}; saved events: {log_path}')
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:
```sh
export A2A_URL=http://127.0.0.1:9999
export A2A_ROW=<your-row-id>
.venv/bin/python a2a_client.py
```
Read the saved `artifactUpdate` parts and final status. Put the useful result on the **same** row, using a note
while work continues or a resolution when you deliberately close it. The script leaves the row owned and open:
```sh
rig queue update "$A2A_ROW" --note 'A2A result: <answer, artifact reference, and any limits>' --json
# Only when your work on the row is actually finished:
rig queue update "$A2A_ROW" --state done --closure-reason no-follow-on --resolution '<your assessed outcome>' --json
```
If the stream fails, retain its exception and log. The peer may still be working; inspect the known task before
resending. When the task asks for input, set `A2A_TASK` and `A2A_CONTEXT` to its recorded IDs and `A2A_TEXT` to the
answer, then run the client again. For a new follow-up, unset `A2A_TASK` and keep `A2A_CONTEXT`: a completed task
is not reopened. Unset `A2A_TEXT` when the next outbound task should again use its row body.
## Inbound: an A2A client reaches an OpenRig seat
Run the following server in a custodian seat's environment, with `A2A_SEAT` set to the existing worker seat's
canonical session name. The worker is a separate participant: it watches its queue, claims the row, does the work
and writes a resolution. A deterministic shell worker is enough to test this path; a model is optional.
Save as `a2a_front.py` beside `queue_io.py`:
```python
import asyncio
import json
import os
import uvicorn
from starlette.applications import Starlette
from a2a.helpers import get_message_text, new_task_from_user_message, new_text_message, new_text_part
from a2a.server.agent_execution import AgentExecutor
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from queue_io import rig
seat = os.environ['A2A_SEAT']
rows, canceling, published = {}, set(), {}
class Front(AgentExecutor):
async def execute(self, context, events):
task = context.current_task or new_task_from_user_message(context.message)
if not context.current_task:
await events.enqueue_event(task)
update = TaskUpdater(events, task.id, task.context_id)
published[task.id] = asyncio.Event()
try:
text = get_message_text(context.message)
if task.id not in rows:
row_id = 'qitem-a2a-' + task.id
body = json.dumps({'taskId': task.id, 'contextId': task.context_id, 'request': text})
await rig('queue', 'create', '--id', row_id, '--destination', seat, '--body', body,
'--summary', 'Inbound A2A task', '--no-nudge', '--full', '--json')
rows[task.id] = row_id
else:
row_id = rows[task.id]
await rig('queue', 'update', row_id, '--note', 'A2A answer: ' + text, '--json')
working = False
while True:
row = await rig('queue', 'show', row_id, '--full', '--json')
if task.id in canceling:
await asyncio.Future() # cancel() owns the terminal event.
if row['state'] == 'done':
if not row.get('resolution'):
await update.failed(new_text_message('Row closed without a result resolution.'))
else:
await update.add_artifact([new_text_part(row['resolution'])], name='seat-result')
await update.complete(new_text_message('Seat closed row ' + row_id))
return
if row['state'] in ('failed', 'denied', 'handed-off'):
await update.failed(new_text_message(row.get('resolution') or ('Row ' + row['state'])))
return
if row['state'] == 'canceled':
await update.cancel(new_text_message('Queue row canceled.'))
return
if row['state'] == 'in-progress' and not working:
await update.start_work(new_text_message('Seat claimed ' + row_id))
working = True
history = await rig('queue', 'transitions', row_id, '--full', '--json')
notes = [t.get('transitionNote') or '' for t in history]
conversation = [n for n in notes if n.startswith(('A2A question: ', 'A2A answer: '))]
if conversation and conversation[-1].startswith('A2A question: '):
await update.requires_input(new_text_message(conversation[-1][14:]))
return
await asyncio.sleep(1)
except asyncio.CancelledError:
if task.id in canceling:
await published[task.id].wait() # See SDK 1.1.0 cancellation note below.
raise
except Exception as error:
await update.failed(new_text_message(str(error)))
async def cancel(self, context, events):
canceling.add(context.task_id)
try:
row_id = rows[context.task_id]
await rig('queue', 'update', row_id, '--state', 'canceled', '--resolution',
'A2A client requested cancellation; worker stop not yet confirmed.', '--json')
await TaskUpdater(events, context.task_id, context.context_id).cancel(
new_text_message('Queue cancellation committed; worker stop is a separate observation.'))
finally:
published[context.task_id].set()
card = AgentCard(name='OpenRig queue: ' + seat, version='example-1',
description='A worker seat returns its queue resolution.',
default_input_modes=['text/plain'], default_output_modes=['text/plain'],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[AgentInterface(protocol_binding='JSONRPC', url='http://127.0.0.1:19092', protocol_version='1.0')],
skills=[AgentSkill(id='queue-work', name='Queue work', description='Work assigned to ' + seat, tags=['queue'])])
handler = DefaultRequestHandler(agent_executor=Front(), task_store=InMemoryTaskStore(), agent_card=card)
app = Starlette(routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, '/')])
uvicorn.run(app, host='127.0.0.1', port=19092)
```
Start the front, then use a second terminal for the outside client:
```sh
# Custodian terminal:
export A2A_SEAT=<worker-session-from-rig-ps>
.venv/bin/python a2a_front.py
# 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".'
.venv/bin/python a2a_client.py
```
The client waits while the worker does its part. In the **worker's own environment**, inspect and claim the row:
```sh
rig queue list --owned --full --json
export ROW=<the-new-qitem-a2a-id>
rig queue show "$ROW" --full --json
rig queue claim "$ROW" --json
rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution 'hello from the receiving seat' --json
```
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
`A2A answer: ` transition with `rig queue transitions "$ROW" --full --json`, then finishes that row. These prefixes
are this example's small convention, not built-in OpenRig fields. A new follow-up omits the completed task ID and
keeps the context ID; it produces a new row.
## Cancel and read the outcome honestly
For a protocol-1.0 JSON-RPC peer, a direct control request includes `A2A-Version: 1.0`. For example, after the task
is bound and working (substitute its ID; use `GetTask` with the same parameters to inspect it):
```sh
curl -sS "$A2A_URL" -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
--data '{"jsonrpc":"2.0","id":"cancel-1","method":"CancelTask","params":{"id":"<task-id>"}}'
```
Read the JSON body and task state, not just HTTP status. A pipe trial received HTTP 200 carrying JSON-RPC
`-32009 VERSION_NOT_SUPPORTED` when the version header was omitted. That was an error, not a completed task.
The SDK handles versioned calls; raw requests must follow the peer's advertised interface and
[protocol version rules](https://a2a-protocol.org/v1.0.0/specification/).
SDK 1.1.0's default V2 handler cancels the running producer **before** awaiting the executor's `cancel` callback.
If that callback awaits a queue write, the producer can close the event channel first. The inbound trial then
returned stale working state even though the row was canceled. The event in the example keeps that producer
alive until the callback publishes its outcome. This is specific to the exercised ordering; see the
[pinned SDK implementation](https://github.com/a2aproject/a2a-python/blob/v1.1.0/src/a2a/server/agent_execution/active_task.py).
Test it again when changing SDK versions, including an awaited external write rather than an immediate callback.
Keep these facts separate:
| A2A observation | What it means for the queue or worker |
|---|---|
| Submitted | The A2A front accepted a task; a row may not yet be created. |
| Working | This front observed the receiving seat's claim. |
| Input-required | A question needs answering; this example leaves the row in-progress. |
| Completed + artifact | The worker's stored result was returned; your assessment still belongs to you. |
| Canceled | This inbound front committed queue cancellation; an outside peer's cancellation says nothing about your outbound row. Verify separately that the worker noticed and stopped. |
| Failed / JSON-RPC error | Retain the actual error and any row effect; do not report success. |
| Disconnect / timeout | The last known state remains all you know. Inspect before retrying. |
There is no one-to-one A2A/queue state mapping. The example does not follow handoffs to another row, so it reports
that limit instead of pretending the successor's work completed. Stopping the front only detaches clients; it does
not cancel its queue rows or stop their workers. Cancel or hand off outstanding work deliberately before stopping
your server. Acknowledged cancellation does not establish that a model provider stopped spending.
## What was exercised, and what remains open
The initial trials used an OpenRig 0.7.0 candidate and SDK 1.1.0 on Linux. Outbound discovery, streamed output and
artifacts used the official Hello World sample. Deterministic peers covered input-required, cancellation, failure
and concurrency. Inbound used a private daemon and a separate no-model queue worker: five artifacts matched the
stored resolutions, two tasks overlapped without crossing IDs, and completed-task reuse left the old result
unchanged. The successful cancellation check also observed the worker stop, beyond receiving the protocol reply.
A later inbound check used an official SDK 1.1.0 client and front, an OpenRig queue row, and Pi 1.4.0 running
`openai/gpt-4o-mini`. One marker answer reached the row's resolution and the client's completed artifact. A
custodian performed the queue writes; model tools were disabled. That run used a 120-second streaming read
timeout and prompt RPC event draining, changed together. It did not separately test the unbounded read used above.
These results do not establish model quality or provider billing. Other framework versions, model tool use,
public deployment and authentication, stream reconnect, durable bridge restart, and cancellation before row
binding were not exercised. The inbound example holds A2A tasks and their row mapping in memory: keep it running for the conversation;
after a crash, inspect retained OpenRig rows before any replacement run. It binds loopback and supplies no public
authentication layer. Use it on your own machine or a trusted private path, not as an unauthenticated public gateway.
+77
View File
@@ -0,0 +1,77 @@
# Handing work to an ACP agent
`rig acp run` gives one queue row's task to an agent that speaks the
[Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as OpenClaw's `openclaw acp`, and records what
happened on that same row. The seat that runs it is the row's custodian; the agent is recorded as the executor.
```sh
rig acp run <qitemId> --cwd <work folder> -- <the agent's ACP command> [args...]
```
The row's notes get, in order:
- **the binding:** the agent's name and version, the ACP protocol version, whether it can load sessions, and the session;
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output;
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
close it or hand it on with your own resolution, as you would any other.
## When the agent asks permission
OpenRig holds no permission policy of its own. The agent's own settings decide what it asks, and an owner decides
the answer. Each request becomes a queue row to `--permissions-to` (this seat by default), listing the action, its
target, the options the agent offered and the run, session and request it belongs to. Answer on that row with the
option's id:
```sh
rig queue update <permission row> --state done --closure-reason no-follow-on --resolution allow-once
```
Answer on the permission row itself; don't hand it off, because the run waits on that row. The answer goes back to
that one request, once. The turn waits for it; there's no deadline. An answer that isn't one
of the offered options is noted and the question is asked again. If the turn is cancelled first, the permission row
is closed as stale and nothing is sent. Whoever answers can follow their own written policy; that choice is theirs.
## Cancel, detach and stop
These are different things, and the row keeps them apart:
- **Cancel** (Ctrl-C, or `rig acp cancel <runId>` from another terminal) asks the agent to stop its turn. The row
records when it was requested and when the agent acknowledged it. Whether the model provider stopped work, and what
it cost, stays **unknown** unless you check the provider's own usage.
- **Detach** (a second Ctrl-C, or the run ending normally) closes the agent's connection. That doesn't stop
the agent's runtime.
- **Stop:** when the run ends, it reports whether any process carrying the run's environment marker remains:
`settled` (none), `incomplete` (with their process ids) or `unknown` (the process table couldn't be read). Only
processes that inherited the marker are seen, so one the agent started with a cleared environment, or through a
service manager, isn't. It never touches a process it didn't launch. To stop what's left, use the runtime's
own stop command, through `rig acp stop <runId> -- <that command>` so the result lands on the row.
A gateway the agent connects to but didn't start, such as OpenClaw's Gateway, isn't a process of the run. Leave a
shared one running; stop your own with its own command.
## Reading the outcome
- `completed`: the agent ended its turn with output.
- `unknown`: the turn ended, but nothing shows the work happened. That covers an `end_turn` with no output and no or
zero usage (an agent can end a turn this way after its provider refused the request), an `end_turn` whose text
reports an interruption (OpenClaw's `[OpenClaw interruption]`), and a connection that closed before the turn ended.
Check the agent's own log before you treat it as done.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Notes for specific agents
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
example, `rig acp run <row> -- openclaw acp --url ws://127.0.0.1:18789 --token-file <file>`. Its permission requests
come from the Gateway's exec approvals.
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
+7 -5
View File
@@ -177,6 +177,7 @@ defaults:
| `runtime` | string | no | — | Default runtime for this agent. Precedence: the rig spec member's `runtime`, then the profile's `preferences.runtime`, then this, then `claude-code`. |
| `model` | string | no | — | Default model, with the same precedence. |
| `effort` | string | no | — | Default reasoning effort, with the same precedence. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking <level>` (Pi's levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; passed as written). A blank or non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Default Claude Advisor model, with the same precedence. Passed as the session-scoped `--advisor` flag, independently of the primary model; never written to shared Claude settings. Blank or non-string values are ignored with an advisory. Other runtimes ignore it. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
@@ -259,10 +260,11 @@ profiles:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `summary` | string | no | — | Profile description. Spec review surfaces read a profile `description` instead. |
| `preferences` | object | no | — | Runtime/model/effort preferences for this profile. |
| `preferences` | object | no | — | Runtime/model/effort/Advisor preferences for this profile. |
| `preferences.runtime` | string | no | — | Preferred runtime. |
| `preferences.model` | string | no | — | Preferred model. |
| `preferences.effort` | string | no | — | Preferred reasoning effort. |
| `preferences.advisor` | string | no | — | Preferred Claude Advisor model; overrides `defaults.advisor` and can be overridden by the member. |
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. `restore_policy` can only narrow (`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`); `compaction_strategy` and `mechanic` take the most specific value (defaults, then profile, then member). |
@@ -288,7 +290,7 @@ The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtim
empty list, is refused: hooks ship inside plugins.
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the seat's runtime skill folder (`<cwd>/.claude/skills` or
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
`<cwd>/.agents/skills`), then the `skills/` folder beside the RigSpec, then the home skill folder, then the managed
skills root. Pi looks only in the `skills/` folder beside the RigSpec. OMP does no filesystem discovery, so its
`uses.skills` references need declared resources. A discovered skill's ID is its `SKILL.md` frontmatter `name`, and a declared resource wins over a discovered skill with the
@@ -368,10 +370,10 @@ version.
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter: Claude to `<cwd>/.claude/skills/<id>`, Codex to `<cwd>/.agents/skills/<id>`, Pi and OMP to the seat's own agent directory under OpenRig's state. |
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder (`.claude/skills/<name>` or `.agents/skills/<name>`) under their plain names. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
*Fields marked with `*` are optional.
@@ -459,7 +461,7 @@ restore behavior, and the distinction between readiness and verified proof.
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Depends on the file | A path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; otherwise a `.md` file is `guidance_merge`, and any other file is `send_text` |
| `auto` | Depends on the file | A seat's own role (a file marked `orientation: role`, or at a `guidance/role.md` path) is `send_text`, so pod-mates never overwrite each other's role in the shared file. A seat launched before this rule keeps the hint saved at its launch, which restore (`rig up <rig>`), `rig seat launch --fresh` and handover replay, until the rig is instantiated from its spec again: `rig down <rig>`, then `rig up <spec file>`, which starts a new generation with fresh conversations. Otherwise a path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; a `.md` file is `guidance_merge`; and any other file is `send_text` |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
+89 -2
View File
@@ -244,6 +244,15 @@ 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
@@ -321,6 +330,12 @@ not the destination's file contents.)
- `rig seat launch <seat> --fresh --stop --reason <text>`, which starts a blank conversation; or
- `rig handover <seat> --source rebuild --reason <text>`, which carries durable context.
For Codex rebuilds, OpenRig also carries a complete supported native permission policy read from the live
predecessor's own conversation and process, without adding fresh-launch grants. A later recorded seat permission
or policy choice, or a differing declared successor profile, takes precedence. Missing, stale or unsupported observations keep the existing launch
behaviour and are reported as unverified in the handover result; requested modes and profiles alone don't prove
preservation. The result reports what was emitted, not native successor equivalence.
**Adopt it promptly.** The saved directory doesn't move the running process, but background work can use it before
adoption. Saved configuration isn't the live process's directory, and these examples aren't the only consumers. For a
running Claude seat:
@@ -349,10 +364,37 @@ 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, including a restore whose resume falls back to a fresh conversation
- `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
- Default: `[fresh_start, restore]` (both)
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.
**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).
---
@@ -392,6 +434,11 @@ 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 |
@@ -517,6 +564,46 @@ remain shared.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
file-edit permissions allow the seat to write there.
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
```sh
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
```
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
attempt keeps its original path through the post-compaction restore request. The root gets an
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
atomic rename. Use a dedicated root: a conflicting
ignore file or an unwritable configured directory stops preparation with
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
The setting applies to new attempts without restarting the daemon.
The managed `/compact` command keeps its 600-character budget. When the selected map path or
the attempt's compaction instruction does not fit, the existing restore request carries it;
it never substitutes a newer preparation root or instruction. An explicitly selected root's
published map is also named in that request when the path fit in `/compact`. The default root
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
optional map completed at that attempt's path, or says that no map was required.
The compaction preparation and command-submission tests cover manual and automatic attempts,
long paths and instructions, settings changed during preparation, directory failures and
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
they do not prove a live Claude session's permission choices or compaction behavior.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
+55 -3
View File
@@ -41,6 +41,31 @@ 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
as the rig's `attentionCount`. It also serves `arbitratedNeedsInput`: `true` for observed
input, `false` for an observed clear, and `null` when no authoritative needs-input source
answered. `needsInputEvidence` retains that source's rung and observation time, or `null`.
A zero prompt count alone is not an observed clear. Absent arbitration evidence leaves
the existing live-hook fallback in effect; lifecycle, startup, held and error signals
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
provenance. The TUI uses the served flag for attention glyphs and totals, and shows the
needs-input evidence in seat detail, including a hook-only prompt. With an older daemon,
attention stays unknown while its existing prompt, startup and error hints remain visible.
## Derived diagnoses — computed at read time, never stored
- **PARKED** — the disease: `(activity = idle-at-prompt OR needs-input pending) ×
@@ -77,7 +102,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/Stop/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/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
@@ -106,7 +131,33 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
without `agent_end`) leaves the seat working up to the 15 s window, then unknown, never idle. After a
daemon restart, a runner seat takes its last recorded boundary from the hook store, for its
current session and occupant generation only, so an idle seat still reads idle before it
posts again.
posts again, provided that report is fresh: a pi runner's idle boundary states an interval
(below) and lapses to unknown once it is older than two of them.
A pi runner also posts an idle `Stop` (`idle_heartbeat`) every three minutes while it waits, and states that
interval as `livenessSeconds` on every `Stop`: a newly ready runner reads idle at once, and an idle boundary that
carries an interval holds for two of them, then lapses to unknown, so a runner killed without saying so stops reading
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
@@ -132,7 +183,8 @@ LIFECYCLE); see the
- **Attention codes:** `trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`,
`mcp_gate`, `bypass_consent_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`).
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
+11
View File
@@ -0,0 +1,11 @@
# Checked seat stop (HTTP)
Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-confirmed stop. A successful response has this shape:
```json
{"contract":"seat-stop.v1","expected":{"nodeId":"<node ID>","sessionId":"<latest session ID or null>"}}
```
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
+42
View File
@@ -67,3 +67,45 @@ 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.
@@ -0,0 +1,13 @@
# Install as $OPENRIG_HOME/community-harnesses/cursor.yaml, with openrig-cursor-launch on the seat's PATH.
format: 1
name: cursor
command: openrig-cursor-launch
maintainer: Your name or team
limits:
- 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
@@ -0,0 +1,68 @@
#!/bin/sh
# A reference launcher for Cursor'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:
# - a new launch: `cursor-agent create-chat` prints a new chat id; the launcher reports it, then reopens that chat;
# - a resume (OPENRIG_RESUME_REF set): it reopens the chat it was asked for and reports that id, or, when Cursor holds
# no such chat, reports it missing and starts nothing.
# Cursor's chat-id facts come from the probe in OpenRig issue #352, and the chat store's location from Cursor's own CLI
# (2026.10.22).
set -eu
producer="openrig-cursor-launch 2"
cursor="${OPENRIG_CURSOR_AGENT:-cursor-agent}"
# Each seat gets its own Cursor config folder unless CURSOR_CONFIG_DIR is already set. Cursor keeps cli-config.json
# (`--model`, approval settings) and the chats there, so a seat doesn't rewrite the person's own ~/.cursor. Cursor's
# login isn't in it: Cursor keeps that in ~/.config/cursor/auth.json on Linux and in the Keychain on macOS.
CURSOR_CONFIG_DIR="${CURSOR_CONFIG_DIR:-${OPENRIG_HOME:-$HOME/.openrig}/state/cursor/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}}"
export CURSOR_CONFIG_DIR
mkdir -p "$CURSOR_CONFIG_DIR"
# Write this attempt's record, all at once, where OpenRig asked for it (nothing when it didn't ask).
report() {
[ -n "${OPENRIG_LAUNCH_RESULT:-}" ] || return 0
tmp="$OPENRIG_LAUNCH_RESULT.$$.tmp"
printf '%s\n' "$1" > "$tmp"
mv "$tmp" "$OPENRIG_LAUNCH_RESULT"
}
plain_id() { case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; *) return 0 ;; esac; }
# Where Cursor keeps a chat: <config folder>/chats/<md5 of the working directory>/<chat id>/store.db. Cursor hashes
# its process's working directory, which is the physical path, so this hashes `pwd -P`.
chat_store() {
if command -v md5sum >/dev/null 2>&1; then sum=$(pwd -P | tr -d '\n' | md5sum | cut -d' ' -f1)
else sum=$(pwd -P | tr -d '\n' | md5 -q); fi
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; }
# Cursor's `--resume` with an id it doesn't hold exits 0 on a new, empty chat, so check first. Gone: say so, start nothing.
if [ ! -f "$(chat_store "$id")" ]; then
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"error\":\"missing\",\"producer\":\"$producer\"}"
echo "openrig-cursor-launch: Cursor has no chat $id in $CURSOR_CONFIG_DIR for $(pwd -P); starting nothing" >&2
exit 1
fi
else
# create-chat prints the id but doesn't exit (see #352): read the first line, then stop it.
out=$(mktemp)
"$cursor" create-chat > "$out" 2>/dev/null &
pid=$!
i=0
while [ "$i" -lt 100 ] && [ ! -s "$out" ]; do sleep 0.1; i=$((i + 1)); done
kill "$pid" 2>/dev/null || true
id=$(head -n 1 "$out" | tr -d '\r\n\t ')
rm -f "$out"
# No usable id: report nothing, so OpenRig shows the identity as unavailable, and start Cursor on its own chat.
plain_id "$id" || exec "$cursor" --trust "$@"
fi
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"nativeRef\":\"$id\",\"producer\":\"$producer\"}"
exec "$cursor" --resume "$id" --trust "$@"
@@ -0,0 +1,19 @@
# 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
# 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.
terminal:
client_env_passthrough:
- OPENRIG_SESSION_NAME
- OPENRIG_NODE_ID
- OPENRIG_HOME
- OPENRIG_URL
- OPENRIG_HOST
- OPENRIG_PORT
- OPENRIG_DB
# Your own settings go beside it (the guide says which): the model and provider, and agent.max_turns if you want a cap.
@@ -0,0 +1,12 @@
# Install as $OPENRIG_HOME/community-harnesses/hermes.yaml, with openrig-hermes-launch on the seat's PATH.
format: 1
name: hermes
command: openrig-hermes-launch # no model, provider or --max-turns here: they go in the seat's Hermes config.yaml
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
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
@@ -0,0 +1,34 @@
#!/bin/sh
# A reference launcher for Hermes Agent'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): Hermes creates the session titled with that name;
# - a resume (OPENRIG_RESUME_REF set): Hermes reopens that session, and never creates one. A name that matches nothing
# ends with Hermes's own "No session found" error, so a gone conversation isn't replaced by a new one.
# Put the model, provider and max turns in the seat's Hermes config.yaml, not on the command line. Hermes v0.22.4's CLI
# creates a `-c <name> --create-if-missing` session with no launch flags, then reopens it and refuses any creation flag
# that differs, so such flags fail even on a new name. Other arguments go only to a new session, never to a resume.
set -eu
hermes="${OPENRIG_HERMES:-hermes}"
# Each seat gets its own Hermes home, so seats don't share sessions or a gateway, and a seat's stop concerns only its own.
# Its settings (model, provider, agent.max_turns) come from that home's config.yaml, and its key from the environment
# (an OpenRouter key, say), not from the person's ~/.hermes.
HERMES_HOME="${OPENRIG_HOME:-$HOME/.openrig}/state/hermes/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export HERMES_HOME
mkdir -p "$HERMES_HOME"
# Hermes's tools see its shared gateway's environment except for the names a profile passes through, so a seat whose
# config.yaml doesn't pass OPENRIG_SESSION_NAME would run `rig` as another seat, or as none (see config.yaml.example).
if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_CONVERSATION" --create-if-missing "$@"
fi
echo "openrig-hermes-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,16 @@
# Install as $OPENRIG_HOME/community-harnesses/openclaw.yaml, with openrig-openclaw-launch on the seat's PATH.
format: 1
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
- 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
@@ -0,0 +1,70 @@
#!/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.
set -eu
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
mode=local
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
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 '
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); }
});'; 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"
fi
fi
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
exec "$openclaw" tui --local --session "$key" "$@"
@@ -0,0 +1,100 @@
#!/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
@@ -0,0 +1,15 @@
# 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
+351
View File
@@ -0,0 +1,351 @@
# Community harness presets
A community harness is a coding harness an outside contributor maintains. You install it as one small data file, and a
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
installed.
```yaml
format: 1
name: example-harness # must match the file name: lowercase letters, digits and hyphens
command: example-launch # one program and its arguments, typed into the seat's pane
maintainer: Example Maintainer
limits: # what the maintainer says doesn't work
- fork unsupported
launch_result: true # optional: the launcher reports the conversation it created (below)
# 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 `$(…)`
is refused: put it in a script and name the script.
- **Built-in runtime names are reserved.** A preset can't replace `claude-code`, `codex` or the other built-ins.
- **Unknown fields are refused,** so a preset written for a newer OpenRig says so instead of half-working.
- **Fork isn't supported, and resume only as described below.** Neither is placing skills, guidance or startup files. Except
for assisted startup below, the seat is called ready while something other than a shell runs in its pane, which says
nothing about which conversation it is.
### 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.
## Reporting the conversation a launch created (`launch_result: true`)
Some harnesses create their own conversation id before they start. Cursor's `cursor-agent create-chat` is one. With
`launch_result: true`, the harness's launcher tells OpenRig that id for each launch.
1. **OpenRig names one file per launch attempt.** It starts `command` with two variables:
- `OPENRIG_LAUNCH_RESULT`, a new file path inside `$OPENRIG_HOME/state/launch-results/`;
- `OPENRIG_LAUNCH_ATTEMPT`, this attempt's id.
2. **The launcher writes one JSON record to that path,** then starts the harness:
```json
{ "format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "cursor", "nativeRef": "<the conversation id>", "producer": "openrig-cursor-launch 1.0" }
```
3. **OpenRig reads that attempt's record once,** waiting up to 20 seconds for it. It records `nativeRef` on the session,
and the seat shows its identity as **reported by its launcher, not observed**. That's what the launcher says, not
proof that the harness used it.
The seat still starts, with its identity **unavailable** and the reason named, when the record:
- wasn't written;
- belongs to another attempt or another harness;
- isn't JSON, isn't format 1, or is larger than 64 KiB;
- has an unusable `nativeRef` (empty, too long, or containing spaces or control characters);
- names no producer;
- or is reached through a link.
None of these means the conversation is gone.
A reported id doesn't make the harness resumable on its own. Without `resume: true`, nothing resumes it: a restore
stops and asks, as it did before (re-run it with `--fresh <seat>` only if you want a new conversation), and elsewhere a
resume says it's unsupported, and a new conversation starts only where the caller allows a fresh start.
**Good to know:**
- **The variables are passed with `/usr/bin/env`,** so `launch_result` needs a POSIX system.
- **A launcher that never writes its record** adds up to 20 seconds to each launch, while OpenRig waits for it.
- **Records are kept,** one small file per attempt, under `$OPENRIG_HOME/state/launch-results/<session>/`. A record
larger than 64 KiB isn't read.
## Letting OpenRig name the conversation (`names_conversation: true`)
Some harnesses take a conversation name from the caller and create that conversation, or reopen it. Hermes's
`hermes chat -c <name> --create-if-missing` is one. With `names_conversation: true`, OpenRig names each new conversation
and the harness uses that name, so no record is needed.
- **A new launch:** OpenRig starts `command` with `OPENRIG_CONVERSATION` set to a new name, `openrig-<seat>-<8 hex digits>`
(at most 100 characters). Each launch gets its own name, so two seats never share a conversation.
- **What the seat shows:** OpenRig records that name on the session, and the seat shows its identity as **named by
OpenRig, not observed**. That's the name OpenRig passed, not proof that the harness used it.
- **Not with `launch_result`:** a preset declares one or the other. Either the launcher reports the conversation, or
OpenRig names it.
## Reopening a conversation (`resume: true`)
**A named conversation:** with `names_conversation`, a resume starts `command` with `OPENRIG_RESUME_REF` set to the saved
name. The harness reopens it and must never create one under that name. Nothing reports back, so:
- **readiness is process-only:** the seat reads as ready while something other than a shell runs in its pane. That
doesn't show the named conversation exists: a harness (or a launcher's own check) that hasn't yet found it gone reads
as ready, and the saved name stays unverified until something observes it;
- **never creating one is the launcher's job:** a resume must not create a conversation under the saved name. When it's
gone, the harness or launcher ends with its error and the pane is back at a shell; the seat keeps its saved name, and
OpenRig starts nothing new, though the seat may already have read as ready;
- **a former runtime's id** isn't passed to the harness, as below.
**A reported conversation:** with `launch_result`, a resume (a restore included) starts the launcher with `OPENRIG_RESUME_REF` set to the
conversation to reopen. The launcher reports what happened in the same attempt's file:
- **Reopened:** the usual record, with that conversation as `nativeRef`.
- **Gone:** `{ "format": 1, "attempt": "…", "harness": "cursor", "error": "missing", "producer": "…" }`. The launcher
starts nothing. OpenRig reports the conversation missing and starts nothing either: it never replaces it with a new one.
For a reported conversation, OpenRig also says so plainly when:
- **no usable record is written:** the resume isn't confirmed. The seat needs attention and keeps the conversation it
asked for; nothing is restarted;
- **the record names a different conversation:** the seat needs attention and keeps the conversation it asked for.
OpenRig doesn't switch it to the reported one;
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
Agent's CLI.
- **The launcher:** it creates the session OpenRig named (`-c <name> --create-if-missing`), and on a resume reopens it
without `--create-if-missing`, with its own Hermes home per seat. A name that matches nothing ends with Hermes's own
"No session found" error.
- **Settings go in the seat's Hermes `config.yaml`, not in flags:** Hermes's CLI creates a named session with no launch
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
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
runs as whichever seat started the gateway, or as no seat; with it, each seat runs `rig` as itself. To check, ask the
seat's Hermes to run `rig whoami --json`. The launcher warns when a seat's `config.yaml` doesn't pass
`OPENRIG_SESSION_NAME`. This covers Hermes's CLI and TUI, whose clients pass their environment; its ACP clients pass
none.
- **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.
Copy them and maintain them as your own.
## An example: OpenClaw
[`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.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
## An example: Cursor
[`community-harness-examples/cursor/`](community-harness-examples/cursor/) has a preset and a small launcher for Cursor's
CLI.
- **The launcher:** it reports the chat `cursor-agent create-chat` makes, then reopens it. Each seat gets its own Cursor
config folder unless `CURSOR_CONFIG_DIR` is already set. Cursor keeps its chats and `cli-config.json` there, and its
login elsewhere.
- **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.
- **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
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.
+40 -11
View File
@@ -1,7 +1,8 @@
# Edge Types Reference
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`
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`
---
@@ -16,8 +17,7 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
`spawned_by` and `can_observe`):
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
@@ -25,13 +25,13 @@ Five edge kinds are accepted by the pod-aware validator (the legacy flat-node fo
| `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 | no | Source escalates to target |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
## What Edges Actually Do Today
### Launch ordering
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` 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,13 +46,42 @@ 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
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.
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.
### Identity projection
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.
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.
### Attach hint heuristic
@@ -64,7 +93,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`)
daemon path that follows `delegates_to` or fallback `escalates_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
@@ -128,7 +157,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 `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
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.
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
@@ -0,0 +1,165 @@
# 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`.
+162 -17
View File
@@ -39,6 +39,12 @@ 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
@@ -46,10 +52,14 @@ The `rigs` skill is the procedure: follow it in order. This page is what it look
> 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 and trust in `~/.codex/config.toml`.
> - 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.
> - 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`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
> (`OPENRIG_SKILLS_ROUTER_LINE_ENABLED`).
> - **Starting a team in your project:** adds managed guidance to `AGENTS.md` or `CLAUDE.md`, plus the team's skills
> and plugins.
> - **Permissions:** by default, OpenRig's own kernel gets broader permissions than a project team gets by default.
@@ -181,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.12/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/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
@@ -223,6 +233,15 @@ writes an OpenRig-managed block (mouse on, a longer history) into `~/.tmux.conf`
It is optional for this selected-provider path, not a requirement to fix an unused
provider.
Setup also prints the one line OpenRig can add to each seat's `CLAUDE.md` or
`AGENTS.md`, plus OpenRig's markers around it so it can be removed cleanly: the
exact text it writes, which files it goes in, and what a no gives up. Show the person,
ask, and save their answer with `rig setup --router-line yes` or
`rig setup --router-line no`. No answer isn't a yes: with nothing saved and
nothing set in the environment, the line isn't added. The answer covers only
that line, and so does what setup says a no gives up; setup's "OpenRig may
modify" list names the other writes.
The block keeps `terminal.history_limit` rows of scrollback per pane (default
10000). To keep more, run `rig config set terminal.history_limit 50000`, then
`rig setup` again. A running tmux server keeps its old limit until it restarts
@@ -233,14 +252,19 @@ New managed agents and handover successors on macOS/Linux start from the daemon'
selected launch environment and declared seat values, rather than stored tmux globals.
Values named by tmux's `update-environment` option, including its default SSH-agent
and display variables, come from the daemon's environment.
Normal shell startup and configured tmux shell commands still run. If your custom setup
intentionally supplies values through tmux's global environment, opt in with
Normal shell startup and configured tmux shell commands still run. OpenRig reasserts its
selected `OPENRIG_DB` and configured `TMUX_TMPDIR` when starting the runtime after shell startup.
If your custom setup intentionally supplies values through tmux's global environment,
opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
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
@@ -307,6 +331,16 @@ 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
@@ -370,13 +404,14 @@ 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 you are looking at; the markers are on the tmux client attached to it. When you
carries the Herdr or Rex 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. The
(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
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 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
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
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.
@@ -475,6 +510,11 @@ rig terminal open saved:kernel --provider cmux --json
```
Use `rig terminal status --json` to inspect provider availability and liveness.
To keep rigs in separate Herdr sessions, select one explicitly:
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
`--session default` selects Herdr's default session; omitting the option keeps
the daemon's configured endpoint. See [named terminal sessions](terminal-sessions.md)
for desktop windows, previews and session-specific status.
For the first desktop handoff, use `--window`. Only if the window cannot open,
`rig tui --shared` is the dashboard-only fallback, not the operator's conversation.
@@ -577,8 +617,8 @@ rig ps --nodes --rig starter
Preview the selected seats, models and resources. Plan checks resolution and
preflight for the working directory. The kernel is already your entry point; this step starts the chosen project
team. Read readiness for the project seats, not only daemon health. Resolve a named authentication, trust or permission prompt before giving
that seat work. When a seat stops at such a prompt, `rig up` reports
team. Read readiness for the project seats, not only daemon health. Have the person answer a named authentication, trust, hook-review or
permission prompt before giving that seat work; a launch default, or their answer for another seat, isn't their answer to this one. When a seat stops at such a prompt, `rig up` reports
`Status: partial`, prints `Startup attention (<seat>): <reason>` and exits 1;
`rig ps --nodes` repeats it as "Startup details". The reason ends with the command
to run once the prompt is answered: `rig seat continue <seat>`, which delivers the
@@ -664,9 +704,15 @@ 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. 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.
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.
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.
@@ -716,6 +762,56 @@ 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.
@@ -755,14 +851,16 @@ 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 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. |
| 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. |
| 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. To check your tmux on
a throwaway socket, without touching your own sessions:
`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:
```sh
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
@@ -790,6 +888,24 @@ seats by itself: once a kernel exists, startup skips the built-in kernel boot an
were all lost, not seats that failed. `rig status` then reads `Kernel: skipped` unless some of its seats are running
and ready, so check `rig ps --nodes --rig kernel --full`.
**When every agent stops at once on Ubuntu 22.04.** Ubuntu 22.04's packaged tmux, 3.2a, can exit its whole server
when it's asked about a pane or session that has just closed. OpenRig checks each seat about once a second, so when a
seat's session closes, the next check can stop every agent on that tmux server, and tmux reports "server exited
unexpectedly". This was reproduced with Ubuntu 22.04's `tmux 3.2a-4ubuntu0.3` for a closed pane, a closed session,
and a closed pane in a window that stayed open. If `tmux -V` prints `tmux 3.2a`, put tmux 3.4 or later first on
your `PATH`. Newer versions weren't tested against this crash; #980's reporter runs 3.5a, and none of OpenRig's own
machines runs 3.2a. One way to get one is to build a current release from the tmux project, as that reporter did:
```sh
sudo apt install build-essential pkg-config bison libevent-dev libncurses-dev
# in the unpacked release tarball:
./configure && make && sudo make install # installs into /usr/local/bin
```
Then restart the daemon from a shell where `tmux -V` shows the new tmux. A tmux server that's already running keeps
its version until it exits, so the change applies to servers started afterwards. If a crash already stopped your
agents, recover as for lost tmux sessions in the table above.
If a snapshot is unavailable, the startup view checks the selected seat's
retained startup source and authoritative occupant relation. It reports a
missing or ambiguous source instead of selecting an arbitrary historical row.
@@ -1021,6 +1137,10 @@ 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
@@ -1138,6 +1258,31 @@ and connection, and equal UIDs do not distinguish separate instances belonging
to one account. The check
does not add authentication to the daemon or protect direct HTTP clients.
## Optional database-conflict stop
By default, daemon startup warns and continues when another live daemon is confirmed
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`.
To proceed deliberately for one start, use
`rig daemon start --allow-db-conflict` with the original start options.
The flag sets the override on that child only and does not change the setting.
The underlying `OPENRIG_ALLOW_DB_CONFLICT=1` environment override is also supported:
exporting it applies to every later start from that shell, even after changing
`OPENRIG_HOME`. An inherited copy can also bypass the stop in a later seat or test
home. An inherited `OPENRIG_DAEMON_DB_CONFLICT` overrides that home's file setting;
inspect or unset inherited overrides when changing homes.
Separate instances should use separate databases; the check is a startup observation,
not a lock against later concurrent starts. Changing the setting applies on the next
daemon start.
## Custom settings and precedence
- **OpenRig:** member `permission_policy` overrides rig `permission_policy`.
+13
View File
@@ -390,6 +390,19 @@ 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
+12 -3
View File
@@ -120,6 +120,12 @@ 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
@@ -133,7 +139,9 @@ 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: [#99](https://github.com/mvschwarz/openrig/issues/99).
- 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.
- 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.
@@ -141,8 +149,9 @@ 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). The 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). `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).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
+114
View File
@@ -0,0 +1,114 @@
# 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,6 +165,27 @@ 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.
+9
View File
@@ -0,0 +1,9 @@
# HTTP operation correlation
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, and `member.add`. A client should check this marker before relying on correlation; an older daemon may ignore an unfamiliar request header.
For `POST /api/rigs`, `POST /api/rigs/import`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
The operation contains the rig and node IDs committed by that request. Its `current` field reads the database at response time, including whether recorded resources still exist and the latest recorded session for each node. It does not inspect tmux or prove a launch finished. A reservation with no IDs can mean that execution is still underway or that it failed before creating resources. Replaying an exact request that failed before creating resources returns HTTP 200 with `operation.rigId: null` and `operation.nodeIds: []`; inspect those fields to distinguish it from a replay that recorded resources. Replays never execute the mutation again, retry a launch, or recreate deleted resources. Use a new UUID only when deliberately starting a new execution.
This contract stores neither the original response nor a recovery verdict. An HTTP response can be lost after a database commit. External launch effects are outside the database transaction, so the contract is correlation rather than exactly-once execution of external effects.
+24
View File
@@ -108,6 +108,24 @@ 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
@@ -168,6 +186,12 @@ The read API adds `GET /api/scopes/projects` and optional `project` and
detail also requires the selected mission directory. Existing reads without a
project parameter keep their legacy contract.
`GET /api/scopes?counts=1` is the light list: each mission's slices with only
their `dirName`, `id`, `displayName`, `status`, `stage` and `locks` (and
`sourcePath` for a selected project), read from each slice's node file alone. It
leaves out readiness and proof pairing, which are most of the work and bytes of
the default list. `detail=1` takes precedence when both are given.
## UI Mapping
- `workspace.root` maps to the Project workspace.
+16
View File
@@ -28,6 +28,17 @@ 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
@@ -109,6 +120,11 @@ 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
+13 -1
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.6.12`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.4`. 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,6 +193,18 @@ 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
+8 -2
View File
@@ -36,8 +36,14 @@ own project folder is the same project and still acts as the seat, as before thi
launched before this change, a manual run) the hooks do what they always did. The plugin's activity relay stays quiet next to
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
Codex loads the plugin's hooks once the daemon has installed it through Codex's own `codex plugin add` (see the plugin
README for the config it writes and how to opt out), and each hook has passed Codex's exact-hook review. The Codex
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
OpenRig records its approved bundle in `CODEX_HOME/.openrig-core-hook-trust.json` and leaves Codex's immutable `.hook-trust` baseline untouched.
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
`OPENRIG_NODE`.
+271 -11
View File
@@ -174,7 +174,7 @@ edges:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `version` | string | yes | — | Use `"0.2"`. Any non-empty string validates; a spec is treated as pod-aware because `pods` is an array. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. `rig up <spec> --name <rig>` launches the spec under another name instead (see below). |
| `summary` | string | no | — | Human-readable description. Shown in the spec library, review surfaces and `rig specs preview` (in `rig specs show` only with `--json`). |
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
@@ -188,6 +188,25 @@ edges:
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
**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:
@@ -217,6 +236,131 @@ 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
@@ -354,16 +498,33 @@ 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. 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.
- `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.
- 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. 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.
- 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.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
@@ -435,13 +596,18 @@ 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. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -450,6 +616,36 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `starter_ref` | object | no | — | `{ name }` of an agent starter (`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) whose files lead the seat's startup. Not valid on terminal members or with a fork. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
```yaml
members:
- id: owner
agent_ref: local:agents/implementer
profile: default
runtime: claude-code
cwd: .
model: claude-sonnet-5-5
effort: medium
advisor: opus
```
OpenRig passes `--model claude-sonnet-5-5 --effort medium --advisor opus` to this
Claude session. The primary model stays Sonnet. Advisor resolution uses the member's
`advisor`, then the profile's `preferences.advisor`, then the agent's `defaults.advisor`.
Blank candidates are skipped before applying this precedence. Materialization stores
the resolved value, so node/graph output, exported specs and snapshots include it even
before the first launch. Launching the original spec re-resolves its current preferences.
The selection is supplied again on resume, restore, fresh restart and handover. A fork
uses the resulting member's configuration and keeps its own conversation identity.
This requires a Claude Code version that supports `--advisor`. OpenRig never writes
`advisorModel` to user or project settings. Members sharing a checkout can therefore
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
own defaults/settings apply. Other runtimes ignore this field.
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
@@ -457,7 +653,9 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
- **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`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `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.
### Oh My Pi (`runtime: omp`)
@@ -467,7 +665,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
- **Credentials:** Provision each seat separately, or put the provider's key variable in `recovery.provider_auth_env_allowlist` (for example `ANTHROPIC_API_KEY` or `MISTRAL_API_KEY`). The allowlist accepts the key variable of every provider in OpenRig's OMP provider map, from `anthropic` through `litellm`. A seat receives a key only when its `model` is written as `provider/id`, such as `anthropic/claude-sonnet-4-5`. Short names such as `opus` pass no key. A provider whose key is scoped to one endpoint also accepts that endpoint's variable: `anthropic`, `openai` and `litellm` each take their `<PROVIDER>_BASE_URL` — `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `LITELLM_BASE_URL`. The allowlist admits each variable on its own, so an endpoint named without its key is admitted too, which suits a keyless local proxy. The launch gate admits these endpoint names for OMP seats; the match against the seat's declared `provider/id` happens at the OMP child, which forwards a matching endpoint already in the runner's environment just as it forwards a key. OMP also reads the launch directory's `.env`, so use a trusted working directory.
- **Approval posture:** The default floor is `--approval-mode always-ask`. Because the runner is headless, OMP approval requests are cancelled and the seat stays in needing-attention after the turn ends, until the next agent run starts. A `full_bypass` permission policy selects `--approval-mode yolo`.
- **Model errors:** Errors are printed in the pane. A completed assistant failure with a numeric `errorStatus` of 408, 429, 500, 502, 503, 504, or 529 returns to idle at terminal `agent_end`, allowing an ordinary retry. Until that terminal boundary it stays in needing-attention. Intermediate `agent_end(isTerminal:false)` events do not release attention. A statusless failed retry summary retains that classification only when its attempt matches an announced retry and its final error exactly matches the latest typed transient assistant failure in that attempt. New assistant/turn boundaries invalidate the previous failure; unmatched summaries remain runtime errors. Rejected prompts, auth/configuration failures, exhausted retries without a typed transient status, and untyped errors (including text such as `529 overloaded`) retain attention until the next agent run starts. The runner does not classify diagnostic text. A transient failure does not clear a denied approval or another unresolved runtime failure in the same turn.
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token; restoring it requires `rig up --existing <rig> --fresh <seat>`. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig up --existing <rig> --fresh <seat>`, or restore it manually.
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token, so a restore leaves it awaiting a decision. Start it fresh with `rig seat launch <seat> --fresh --reason "<why>"`, the command `rig start` and `rig up --existing` print for that seat. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig seat launch <seat> --fresh --reason "<why>"`, or restore it manually. `rig seat launch --fresh` refuses a managed seat whose pane is still running unless you add `--stop`, which ends what is running there; add it only when you mean to replace that session.
### Terminal Nodes
@@ -483,6 +681,8 @@ All three must be present together. Any partial combination is a validation erro
A terminal seat no longer waits for a `--fresh` decision just because it has no resume token: on restore it relaunches fresh and re-runs its startup actions. The runtime, identity and replay prerequisites still apply. A runtime mismatch still waits for a decision, an ambiguous occupant still fails, and a missing required replay file still blocks.
`rig send` to a terminal node types the exact text, without the From/To envelope that agent seats get, because the shell would run the envelope lines. The sender's output says so.
### agent_ref Rules
- Must start with `local:` or `path:`
@@ -518,7 +718,11 @@ 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` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
| `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.
### Pod-Local Edges
@@ -553,12 +757,68 @@ edges:
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
### Changing edges on an existing rig
Use `rig edge add` to ensure relationships between seats that already exist. It
accepts a rig name or ID and **fully qualified logical IDs for both same-pod and
cross-pod edges**. This command only changes edges; it does not create pods,
restart sessions or change seat identity.
```sh
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --json
```
Run it once per edge. It returns `added` with the new edge's ID, or `present` with
the existing edge's ID when that `{from, to, kind}` is already there, and then
writes nothing. Existing duplicate rows from other creation paths are retained;
this operation does not create more copies.
Unknown endpoints, an invalid kind or a launch-order cycle leave the graph
unchanged. Both endpoints must belong to the target rig. `--plan` performs the
same validation and returns `would_add` for a new edge without writing anything.
Each `added` receipt includes a rollback command,
`rig edge remove <rig-id> <edge-id>`, which removes exactly that edge in that rig.
Removal also supports `--plan` and `--json`. Removing an edge that is already gone changes nothing
and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports not found, not success.
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
explicit ID. These commands target the local daemon, as `rig add` does.
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
goes to Slack's inbound destination. When an added or removed edge changes either, the receipt says what
moved in `routing`:
`orchestrator: {seat, from, to}` and `representative: {from, to}`, where `null` is none. The plain output
prints a `routing:` line for each. A change that moves nothing has no `routing`, and `--plan` doesn't
preview it.
Changes are persisted immediately and included in `rig export`. Existing snapshots
remain unchanged, and restore reads the **selected snapshot's edges**. To restore
the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
`rig restore <snapshot-id> --rig <rig-id>`.
---
## Startup Block
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 |
@@ -567,7 +827,7 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| `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`. |
| `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. |
#### Delivery Hints
@@ -585,8 +845,8 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| `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 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`. |
| `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). |
### Startup proof selection
+6
View File
@@ -86,3 +86,9 @@ 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)).
+9 -1
View File
@@ -165,6 +165,12 @@ 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
@@ -174,7 +180,9 @@ 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.
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.
The three sections project into the UI's one review structure: a vertical
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
+104 -7
View File
@@ -1,22 +1,116 @@
# Per-seat terminal delivery
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.
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.
| 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`, `--force` and `--dangerously-interact` do not bypass the typing guard.
Direct human input remains available.
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
`rig seat status <seat>` shows the seat's terminal interaction account alongside
its native permissions and interruption level. These choices are independent.
The presets `no-peer-interventions`, `help-within-instructions` and `full-control`
set the peer row. Self and OpenRig retain their existing authority unless an
explicit exception changes it. Named helpers and the delegating instruction are
shown; OpenRig does not interpret free-text instructions as executable rules.
~~~sh
rig seat set-terminal-policy reviewer@my-rig --preset help-within-instructions \
--helper lead@my-rig --peer-model deny \
--reason "Help with compaction; keep the review model"
rig seat status reviewer@my-rig
~~~
Each `--peer-compaction`, `--self-compaction`, `--openrig-compaction`,
`--peer-model`, `--self-model` or `--openrig-model` exception takes `allow` or
`deny`. Model includes reasoning effort. Exceptions apply to the whole actor
row: `--peer-compaction allow` also allows unnamed peers. A choice with
exceptions reads as Custom. The setter replaces the complete choice; omit
exceptions to return to the preset's defaults. Outside a managed seat, use `--operator <address>` for
audit attribution. This does not establish an authenticated identity.
Enforcement covers managed compaction and saved model/effort changes only.
The latter take effect at the next managed continuation, not in the current
process. Other interventions remain guidance-only. Managed compaction applies
to Claude seats; it is not offered for Codex or Pi. A policy change during
preparation is checked before further input. It does not undo a submitted
`/compact` or abandon an already-owed restore/audit.
Existing seats retain current behaviour on upgrade. To choose a default for
newly added seats in a rig, add `--rig-default` to the setter above: existing
seats keep their own copied choices. OpenRig's standing managed-compaction
authority stays active unless its exception is deliberately changed.
Caller attribution is cooperative. An unattributed caller retains current
behaviour rather than being labelled the person or a peer. The existing
`rig send --dangerously-interact --reason` path remains available; this policy
adds no restriction there. Its existing-input check applies independently of the
typing mode. Plain tmux is not prevented.
### Deliberate interaction and existing input
A managed `rig send <seat> "<answer>" --dangerously-interact --reason "<why>" can
drive a recognized native menu as before. Otherwise, with the delivery guard
attached, it reads the visible composer under the seat's lease immediately before
pasting, after buffer preparation. Empty input proceeds. A recognized dim
suggestion is displayed separately and counts as empty; typed or pasted text
(author unknown) stops the send with `override_input_present`. An unreadable input
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
not the hidden contents. OpenRig neither holds nor retries this stopped override.
Inspect the shown input in the native terminal, deliberately clear the prompt with
native/tmux controls, then issue the send again. Manual clearing runs outside
OpenRig's lease and observation checks and is not recorded. For a deliberate
unchecked write, add `--type-without-checking` to that same command. It skips the
read and does **not** clear input: the text may join or submit what is already there.
The result names that choice. It requires `--dangerously-interact` and `--reason`.
Observed input is returned only to the caller, never saved in an outbox, held
message, capture-observer record or override audit. The audit keeps classifications,
counts and the choice, without input contents. The lease serializes OpenRig writes;
human typing can still race the capture and paste. Ordinary sends and wakes keep
their existing behaviour.
### Terminal delivery
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
@@ -29,7 +123,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|hold` or the compatible
Use exactly one of `--mode off|draft-aware|openrig-urgent|urgent-only|sends-only|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
@@ -159,6 +253,9 @@ 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
@@ -0,0 +1,17 @@
# 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.
+24 -4
View File
@@ -58,6 +58,15 @@ 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
@@ -137,7 +146,9 @@ part of OpenRig.
## One channel per rig or seat (optional)
By default every human-bound item posts to the one channel from `rig slack setup --channel`.
Every ask to a person posts to Slack by default: that's their Slack subscription's `every-ask` preset. A person who wants
Slack for urgent asks only sets `rig gateway human set <id> slack-subscription urgent-only`: then only an ask marked
`--alert` posts, and the rest wait on Steering and the TUI. An ask marked `--alert` stands out either way (an interrupt with a mention). By default every post goes to the one channel from `rig slack setup --channel`.
To follow each rig separately, post a rig's items, or a single seat's, to its own channel:
```bash
@@ -180,6 +191,14 @@ 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
@@ -188,7 +207,8 @@ 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. 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. `rig slack post-open-asks`
then shows the asks still open. 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" }`
@@ -256,7 +276,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.
open, a decision-resolved notice, or a digest. `rig slack post-open-asks` then shows the asks still open.
## One app across machines (optional)
@@ -303,7 +323,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 ends `from <seat> on <machine>`, using the machine's host id (set
- **Who answered:** every post starts `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.
@@ -102,6 +102,34 @@ transport or inventory. Failed runs preserve already written output; inspect
`unavailable`, `remaining`, call count and stop reason rather than treating a file
as proof of success.
From 0.7.2, `rig project wake --experiment` records its attempts under
`<classifier-version>+experiment`. `rig stream show` labels its classifications
`experiment`, not intake's, and its abstentions don't keep an item from the typed
decisions path below. It still creates no work.
## Typed decisions in the intake turn (`--decide`)
With `decisions.enabled` and the `stream` consumer configured (`rig project decisions
status`), `rig project wake --decide` takes the same options as a wake and asks a typed
decision for each eligible item, within the run's call budget. An accepted answer is
written with the item's classification and becomes owned work (a created or joined queue
row) or a recorded no-action or duplicate; `rig stream show <id>` shows what became of it.
Everything else stays with the intake seat: abstentions, errors, a deferral's reason, and
any item that intake or an earlier version already abstained on (an experiment's
abstentions excepted). The manual path (`rig project classify`, or a wake with
`--decisions`) is unchanged and remains the fallback.
**Turning decisions off, or changing them mid-run:**
- Off means no new provider calls. A run rechecks the settings before every send and halts
with `settings_changed`; an answer that arrives after a change is `stale` and isn't applied.
- Nothing already committed is discarded. Classifications, dispositions and work are kept,
and a disposition accepted before the change but not yet applied is still applied:
`rig project wake --decide` with decisions off asks nothing, takes the classifier lease
and applies those effects (`recovered` in its output).
- One classifier lease serves every path; an effect is applied only by the lease holder.
- Old unclassified items are bounded by stream retention (`retention.stream_days`,
`retention.stream_max_items`), which keeps items any of these records name.
## One bounded wake
From the selected occupant, read candidates using the configured project ID:
+3
View File
@@ -21,6 +21,9 @@ An empty match set can still have more source records: check `page.hasMore` and
`page.nextCursor`. The cursor binds the stream and filters; retain the same filter
options when continuing. Do not use `--start` with `--cursor`.
`rig mcp serve` offers the events read as the `rig_telemetry_events` tool, with the
same parameters and envelope; its `rigId` also takes a rig name.
## HTTP and envelope
These reads use the daemon's existing browser/access boundary:
+308
View File
@@ -0,0 +1,308 @@
# Choose a Herdr session for a terminal view
Use `--session <name>` to open a view in a named Herdr session on the daemon's
host. For example, keep development and production rigs in separate sessions:
```sh
rig terminal open future-rig --provider herdr --session default --json
rig terminal open future-prod-rig --provider herdr --session future-prod --json
rig terminal status --provider herdr --session future-prod --json
```
The explicit provider form places the view in the existing Herdr session
without opening a desktop window. Use Herdr's own session management to prepare
the session. An unavailable selected session returns `herdr_unavailable`; it
does not redirect the view to a different session.
The `session` field in an open result confirms the requested selection. Status
also names the session and returns its resolved control socket in `status.launch`.
Session names are a single non-empty name, such as `future-prod`, rather than a
socket path. `--session default` explicitly selects Herdr's default session.
## Default selection and scope
When Herdr is selected with no `--session`, the daemon uses the endpoint selected when it started:
its `HERDR_SOCKET_PATH`, then its `HERDR_SESSION`, then the default endpoint.
Setting either variable only on a later `rig terminal open` command does not
change that daemon configuration. Pass `--session` for a per-command selection.
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.
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
rig terminal open future-prod-rig --session future-prod --window --json
```
The window launcher, readiness checks, workspace inventory, reuse and layout
application use the selected endpoint. When launched inside Herdr, the current
Herdr endpoint must match that selection to reuse the current terminal. To
place a view in another existing session from such a caller, use the explicit
`--provider herdr` form without `--window`.
An endpoint-mismatch error also gives a command for the caller's own session,
such as `rig terminal open future-rig --session work`. Previously it only
explained how to place the view in the daemon's selected session. The caller's
name comes from the same resolved socket path used by the endpoint check:
`~/.config/herdr/sessions/<name>/herdr.sock`, or `default` for
`~/.config/herdr/herdr.sock`. An explicit `HERDR_SOCKET_PATH` takes precedence
over `HERDR_SESSION` for both the check and the suggestion. Other custom socket
paths keep the existing guidance without guessing a session name, even when
`HERDR_SESSION` is set. Reporting the mismatch opens nothing.
Previews include the selected session in their fingerprint. A preview from one
session cannot authorize an open in another; refresh the preview after changing
the selection. The CLI asks the daemon to confirm named-session support before
requesting an open, so an older daemon cannot silently ignore the option and
write into its default session. An unconfirmed reply after an open is reported
as an unknown outcome; inspect the selected session before retrying.
`--session` applies to Herdr. A named session does not fall back to plain tmux
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 }`.
- `GET /api/terminal/preview?view=<view>&provider=herdr&session=<name>`.
- `GET /api/terminal/status?provider=herdr&session=<name>`.
- The alias `POST /api/rigs/:rigId/terminal/open` accepts the same session and
preview binding, with the rig determining the view.
Malformed names and unsupported provider/session combinations return HTTP 400
before socket calls. A changed preview returns HTTP 409. An unavailable Herdr
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.
+1 -1
View File
@@ -29,7 +29,7 @@ What a seat runs has its own words:
| --- | --- | --- |
| Runtime | What a seat runs: a harness, a terminal, or the stub (a scripted stand-in used in tests). It's the rig spec's `runtime` field and the TUI's runtime column | `claude-code`, `codex`, `terminal` |
| Harness | The agent software around a model | Claude Code, Codex, Pi |
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/`, listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/` ([format](community-harness-presets.md)), listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
| Terminal | A seat with no agent: a shell for infrastructure processes | `runtime: terminal` |
## How the software teams grow
+174 -171
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.6.12
# What changed in OpenRig 0.7.1
This note is for an agent on an install that was just upgraded to 0.6.12. It lists only what behaves differently from
0.6.11 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,181 +9,184 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Twelve migrations run on the first start,
whatever you use: `100`, `104`, `105`, `107` to `115`. They add columns and tables for the typing guard's modes, a
seat's saved effort, Claude's cost estimate, Pi usage, mode and posture categories, workflow scope, seat environment
values, the Codex `--no-daemon` record and `sessions.agent_app`; build two indexes for recent queue activity (about
10 to 12 MB on a long-running install); and rebuild the Slack thread map. There are no migrations `102` or `106`.
- **Claude seats now run the openrig-core plugin's hooks.** The bundled plugin goes from 0.1.9 to 0.1.13. A Claude
seat whose plugins include `openrig-core` (every shipped agent spec selects it) starts with `CLAUDE_CODE_PLUGIN_DIRS`
pointing at OpenRig's copy, so the activity relay, the compaction save and restore hooks and refocus actually run. It
needs Claude Code 2.1.280 or later, which nothing checks. A running seat picks it up at its next launch, restore or
handover. The refocus hook may take up to 60 seconds, so a hung refocus can hold a prompt that long.
- **Six new settings, all off except one:** `output.whoami_peers` (8, used only while `output.compact` is on),
`bundle.services_enabled`, `provider.subscription_moves`, `usage.pi_cost_facts`, `usage.pi_openrouter_charge` and
`transport.verify_input_box`. See "Off by default" below.
## Output that changed shape
- **Exit codes:** eleven client-fixable `rig queue update` refusals now answer HTTP 400 and exit 1 instead of 500 and
exit 2 (`blocker_not_found`, `blocker_not_live` and `blocker_malformed` still exit 2). `rig ask` exits 1 for an
unknown or ambiguous rig. `rig compact` exits 1 (was 2) for `pane_unknown_protected`, and its route answers 409 (was
502). Over the shared transport, a write that throws without a code makes `rig send` exit 1 with HTTP 409 instead of 2
with 500, and a guarded send whose text was written returns `sent: true` with `inputPhase: "written"`.
- **`rig compact` and `rig compact --state` add a `compact` result:** `submitted_unconfirmed` (shown as "submitted,
unconfirmed"), `uncertain` or `not_sent`. "Submitted" means `/compact` and Enter reached the seat, not that compaction
ran.
- **`rig capture --lines N --json`** adds `activity: "unknown"` and `activityNote` when lines were cut.
- **`rig start --last --json`** carries `keptDown` (always) and `warnings` (`seat_status_unreadable`).
- **`rig queue show <id> --full`** on a closed row adds `closingTransition`; plain `rig queue show` on such a row reads
`readView.complete: false` and lists it in `omittedFields`. `rig queue show` returns `resolution` when a close stored
one.
- **After `rig down`,** `rig ps`, the TUI and the rig summary read the rig `stopped`, not recoverable, and
`hasLiveAgents` can read `false`.
- **`rig seat status`** reads `Typing guard: on (hold)` for hold, and its JSON's `desired` and `effective` mean hold only;
`desiredMode` and `effectiveMode` give the mode.
- **`rig usage top`** measures tokens and rates within one conversation and one window, so figures can drop and more
seats read `insufficient_samples`.
- **The execution view** binds a row to a mission by its exact `mission:` tag first; `q1_lanes[].waiting` and
`folded.merge_report` are new.
- **Smaller:** `rig create --json` carries `detail` on failure; `rig status --json` is new; `rig mcp serve` lists 23 tools
(five queue tools added); a JSON body of `null` gets 400 `invalid_json`; `GET /api/events` takes `?replay=`; a Slack
seat post's sender line ends `on <host id>`; `rig ps --nodes` `resumeCommand` can carry `--no-daemon`.
## Seats and restore
- **`rig start --last` leaves down a rig stopped on purpose** (`rig down` or `rig seat stop`) and prints how to bring it
back: `rig start --rigs <rig>`, or `rig up '<name>' --existing`.
- **An ordinary terminal seat relaunches fresh on restore** and re-runs its startup actions; don't answer `--fresh` for
it. A missing required startup file for such a seat blocks the whole restore before anything launches.
- **Restore stops a seat whose current runtime differs from its snapshot's,** before launching it; `--fresh` doesn't
bypass it. Restore from a snapshot taken with the seat's current runtime.
- **A slow Claude restore ends `attention_required`, not failed:** "Claude resume did not finish within Ns; Claude may
still be starting, so the launch was kept". Look at the pane, and once Claude is up run `rig seat clear-attention`.
Each such seat can add up to about the readiness timeout, one after another, so `rig up` can pass its 120-second
wait while the daemon keeps working: check `rig ps` before running it again.
- **"Returned to a shell" now means the process tree shows an idle shell.** "The pane shows a shell that is not a proven
idle shell; a launch script may still be starting …" means the seat may still be coming up. A Pi or OMP seat at a
bare shell reads `attention_required`.
- **A Codex fork whose new conversation ID arrives late** stops at `attention_required` with its startup context kept;
let it finish starting, then run `rig seat continue <seat>`. A handover to a Codex fork can complete with `Fork id:
PENDING`.
- **A seat whose startup failed for a recoverable reason** becomes ready after a successful send or capture.
- **`rig launch` takes a rig's name as well as its ID;** a name two active rigs share stops with `rig_ambiguous`.
- **`rig add`, single-seat `rig launch` and `rig launch --seats` wait up to 120 seconds.** A timeout still isn't a
rollback.
- **A runtime `--version` check gives up after 30 seconds.** `rig bootstrap` blocks on a runtime that's installed but
fails its check, reported apart from one that's missing; `rig up`'s preflight fails a Claude, Codex or OMP check that
doesn't answer (a warning for Pi).
- **Claude login detection:** only a current refusal reads `login_required`, not the "Not logged in" footer alone.
- **Model pins:** `fable` now names `claude-fable-5-1`, a trailing `[1m]` is set aside, and pinned Pi seats are checked
(a `:high`-style thinking suffix is accepted).
- **A custom permission policy saved with CRLF line endings** now takes effect at the next launch or restore, instead
of the advisory floor.
- **`rig scope mission create`** writes `status: planned`; set it `active` to count it in `rig proof show --project`.
With `--workspace <project>`, missions go under the project's declared `missions.root`.
## Sends, wakes and the queue
- **A send to a pane in tmux copy or view mode** leaves the mode and delivers; `pane_in_mode` means the pane couldn't
leave its mode. Leave it in that pane, then send again.
- **`rig send --dangerously-interact` types exactly what you give it.** Enter is added only for a complete answer
staged in a recognised Claude Code or Codex input box; anywhere else, put CR in the payload. With `--verify` it reads
unchecked.
- **A Codex seat reads "needs you" only when a real prompt is visible;** a pending permission request reads
`permission_request_pending` first, and an unreadable pane gets `permission_request_unverified` after a grace.
Codex's `»` prompt is recognised.
- **Managed `/compact` is short enough to run and is sent only to an idle seat.** Confirm compaction by effect: the
restore request, or the drop in context.
- **A held-back (`retained`) wake doesn't use up a one-shot park timer;** it's tried again at the interval. You don't
need to re-arm after one.
- **A retired watchdog doesn't deliver a late wake.** A row parked on its owner's own repeating reminder reads
`servedByOwnReminder` in `rig parked` and draws no extra parked-owner wake.
- **`rig queue update <id> --summary "<text>"`** alone corrects a row's subject. Close with **`--resolution
"<outcome>"`** to store the outcome on the row.
- **`rig remove` and `rig shrink`** keep a seat that gets work while stopping (409, `retained: true`) unless
`--fallback` takes it. A stale `rig unclaim` is refused with `session_not_current`.
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
## New things you can do
- **Set one seat's reasoning effort:** `rig seat set-effort <seat> --effort <level> --reason "<why>"` (or `--clear`); it
applies at the next managed launch, not to the running process. Check it with `rig seat effort <seat>`.
- **Change a seat's stored permission policy:** `rig policy apply <policy> --seat <seat> --reason "<why>"`. `rig policy
current` and plain `rig ps` don't show it; `rig seat status` does.
- **Give seats environment values:** `env:` on the rig or a member in `rig.yaml`, or `rig up <spec> --env KEY=VALUE` for
one launch. Never put secrets there.
- **Scope a workflow run:** `--project`, `--mission`, `--slice` and `--tags` on `rig workflow instantiate` and `run`;
write a target as `member@{rig}` to follow the bound rig.
- **Read cost:** `rig usage cost [--seat <session>] [--json]`. A missing figure is never $0, and Claude's figure is an
estimate, not an invoice.
- **Hand work over without a shell:** `rig mcp serve` with `OPENRIG_SESSION_NAME` set gives `rig_queue_create`,
`rig_queue_show`, `rig_queue_list`, `rig_queue_claim` and `rig_queue_update`. Nothing wakes that agent.
- **`rig status --json`:** treat `daemon.reachable: null` as unconfirmed, not down.
- OpenRig 0.6.12 adds `rig community-harness list` for installed community-harness presets, showing their maintainer
and declared limits. These harnesses are not tested by OpenRig. Support is launch-only: no resume or fork, no
resource projection or startup-file delivery, and identity remains unconfirmed. A quiet screen is not proof that the
seat is idle. (#1392, #1518)
- **Guidance:** the `rigs` skill offers to join the team before the first operator message, and offers Codex users one
removable rules file; `delegating-work` says to brief a peer by the model `rig ps --nodes --json` shows; the operator
reads `$OPENRIG_HOME/reference/keeping-a-watch.md` when asked to watch or remind, including a low-allowance warning.
- User-visible text says "harness" for the agent software around a model, and "community harness" for one an outside
contributor maintains; `docs/reference/topology-naming.md` defines the terms. (#1506)
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
## Off by default
## Behaviour that changed
- **`output.compact`** now also shortens `rig queue show` and `rig queue transitions` (`--full` for everything) and
bounds `rig whoami --json` peers to `output.whoami_peers` (`--all-peers` lists all).
- **`provider.usage_windows`** now also shows Claude's windows, each seat's configured account and, with
`--all-hosts` or `--hosts`, every registered machine's windows. A Claude account's current availability always reads
`unknown`: don't tell a person an account has room from a seat's reading.
- **`usage.pi_cost_facts`** and **`usage.pi_openrouter_charge`** take effect when a Pi seat next launches or resumes, not
at once; what was recorded stays visible after they're turned off.
- **`provider.subscription_moves`** enables `rig provider moves`. The `openrig-user` skill's section for it mentions apps
that don't ship in this release.
- **`bundle.services_enabled`**, **`transport.verify_input_box`**, the per-seat draft-aware typing guard (`rig seat
set-typing-guard <seat> --mode draft-aware`), `rig bundle create --include-login-files`, `OPENRIG_TMUX_CONTROL=1`
and `OPENRIG_REFOCUS_BUDGET_MS` (4500 to 52000) each act only when chosen.
- **The daemon runs tmux without `/bin/sh`** on Linux and macOS; `OPENRIG_TMUX_SHELL=1` at daemon start keeps the old
path.
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
## Output that changed shape
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
## What to stop doing
- **Stop looking up a rig's ID before `rig launch`;** the name works.
- **Stop expecting `rig start --last` to bring back a rig taken down on purpose;** use `rig start --rigs <rig>` or `rig
up '<name>' --existing`.
- **Stop counting on `--dangerously-interact` to press Enter;** put CR in the payload.
- **Stop taking the preparation prompt's delivery as the compaction's result;** read the `compact` result and confirm by
effect.
- **Stop answering `--fresh` for an ordinary terminal seat on every restore.**
- **Stop branching on exit 2 for a fixable `rig queue update` refusal;** it's exit 1.
- **Stop recreating a seat, or editing the database, to change its permission policy;** use `rig policy apply --seat`.
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
## Known gaps in 0.6.12
## Known gaps in 0.7.1
- **Managed compaction's 600-character budget** rests on one comparison on Claude Code 2.1.282, and nothing confirms
compaction ran.
- **The plugin hooks haven't run through this path in a real session,** and two hook runs for one seat aren't
serialized: a post-compaction record can be lost or a refocus delivered twice.
- **One Slack app across machines was simulated in one process,** never run on two real machines. Relayed events are
trusted from the calling daemon, and `rig slack verify` on the hub doesn't check relayed channels.
- **`env:` values aren't secret,** and members added with `rig grow` can't carry a member-level `env:`.
- **`rig up --env` is not forwarded for flat-node specs or v1 bundles,** although the launch may succeed. Do not rely on
the flag for those formats in this release.
- **The draft-aware guard waits for idle inside the seat's delivery lease,** which can delay other work for that seat.
- **With `bundle.services_enabled` on, `rig up <file>.rigbundle` installs into the current folder** when no `--target` is
given.
- **`rig seat restart --keep-conversation` supports Linux only,** and hasn't been re-run on 0.6.12.
- **The rig status API can call a running rig `blocked`** because of one seat that isn't running: it folds a restore
forecast into current status.
- **A Codex seat can keep reading as at its usage limit** in a constructed screen case not seen in a real session.
- **After `rig scope slice create` says it couldn't verify membership, the slice was kept.** Check `mission.yaml` before
creating it again.
- **A Claude Code seat whose permission request was approved automatically can still read "needs input";** the Codex
case is fixed.
- **Codex team seats aren't asked before lifecycle commands yet.**
- **The send gate trusts the most recently recorded hook;** `rig capture` shows the real screen.
- **A launched team still writes into its working folder;** the operator says what changes before you agree.
- **After a restart, `rig status` shows `Kernel: skipped` whatever the kernel seats' state.** Read them with
`rig ps --nodes --rig kernel --full`.
- **`rig tui --shared` still attaches on the default tmux server,** so use `rig tui` when the daemon runs on a non-default
tmux socket.
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
+8 -33
View File
@@ -1,38 +1,15 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
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`).
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
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
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.
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
@@ -79,16 +56,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create a GitHub Release using the same file:
5. Create the GitHub Release with the notes from step 2:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
```
6. Publish the npm package if that is part of the release flow.
6. Publish the npm package.
## Guidance
@@ -101,5 +78,3 @@ 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://100.95.124.51:17459/` walked end-to-end
- Founder-walk VM at `http://192.0.2.10: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).
+373 -92
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.12",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -28,6 +28,7 @@
"gate": "node scripts/gate-lane.mjs"
},
"engines": {
"node": "^22 || ^24"
"node": "^22 || ^24",
"npm": ">=10"
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.6.12",
"version": "0.7.1",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -47,7 +47,8 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/sdk": "^1.28.0",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -62,6 +63,8 @@
"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"
+257
View File
@@ -0,0 +1,257 @@
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
// agent's permission requests to an owning seat and returns that seat's answer; it holds no permission policy of its
// own. The connector runs as the row's custodian seat and names the agent as executor; it never claims as the agent.
export interface AcpQueueRow {
qitemId: string;
state: string;
destinationSession: string;
body: string;
resolution?: string | null;
tags?: string[] | null;
}
/** The queue verbs the connector uses, as the calling seat. `rig acp` wires these to the daemon's queue API. */
export interface AcpQueue {
show(qitemId: string): Promise<AcpQueueRow>;
claim(qitemId: string): Promise<void>;
/** A note, optionally with a state change. A note-only update stores no resolution (the queue keeps one only on close). */
update(qitemId: string, change: { note: string; state?: string }): Promise<void>;
create(row: { destination: string; body: string; summary: string; tags: string[] }): Promise<string>;
}
export interface AcpRunOptions {
qitemId: string;
/** The agent's program and arguments, e.g. ["hermes", "acp"]. No shell. */
agent: string[];
cwd: string;
/** The calling seat, which must own the row. */
self: string;
/** Who answers permission requests (default: the calling seat). */
permissionsTo?: string;
/** Load this earlier session instead of starting one; refused rather than replaced when it can't load. */
loadSession?: string;
runId: string;
logPath: string;
pollMs?: number;
/** Live progress for the terminal running the connector. */
progress?: (line: string) => void;
/** Resolves when the custodian asks to cancel the turn (Ctrl-C or `rig acp cancel`). */
cancelRequested?: Promise<void>;
/** Resolves when the custodian detaches without waiting (a second Ctrl-C): stdin closes and the turn reads unknown. */
detachRequested?: Promise<void>;
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
exitWaitMs?: number;
}
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
const FINAL_STATES = new Set(["done", "canceled", "handed-off", "failed", "denied"]);
const NOTE_TEXT_LIMIT = 6000;
/** The command a permission row's owner runs; `done` needs a closure reason. */
export const answerCommand = (row: string) => `rig queue update ${row} --state done --closure-reason no-follow-on --resolution <optionId>`;
class DetachedBeforeTurnEnd extends Error {}
interface PermissionOption { optionId: string; name?: string; kind?: string }
export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions): Promise<AcpOutcome> {
const progress = options.progress ?? (() => {});
const pollMs = options.pollMs ?? 2000;
const permissionsTo = options.permissionsTo ?? options.self;
let row = await queue.show(options.qitemId);
if (row.destinationSession !== options.self) {
throw new Error(`${options.qitemId} belongs to ${row.destinationSession}; run rig acp from that seat, or hand the row to ${options.self} first`);
}
if (row.state === "pending") { await queue.claim(options.qitemId); row = await queue.show(options.qitemId); }
if (row.state !== "in-progress") throw new Error(`${options.qitemId} is ${row.state}; rig acp runs an in-progress or pending row`);
mkdirSync(dirname(options.logPath), { recursive: true });
const log = (entry: Record<string, unknown>) => appendFileSync(options.logPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
const child = spawn(options.agent[0]!, options.agent.slice(1), {
cwd: options.cwd, stdio: "pipe", env: { ...process.env, [RUN_MARKER]: options.runId },
});
child.stderr.on("data", (data: Buffer) => log({ direction: "stderr", text: data.toString() }));
const spawned = new Promise<void>((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
const peer = new StdioJsonRpcPeer(child, (direction, packet) => log({ direction, packet }));
let sessionId: string | undefined;
let text = "";
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
let toolCalls = 0;
let lastTool = "";
let lastProgressNote = Date.now();
let turnOver = false;
let cancelRequestedAt: string | undefined;
peer.onNotification("session/update", (params) => {
const { sessionId: from, update } = params as { sessionId?: string; update?: Record<string, unknown> };
if (!update || from !== sessionId) return; // another session's events never reach this row
if (update.sessionUpdate === "agent_message_chunk") {
const content = update.content as { type?: string; text?: string } | undefined;
if (content?.type === "text" && content.text) { text += content.text; progress(content.text); }
} else if (update.sessionUpdate === "tool_call") {
toolCalls += 1; lastTool = `${update.title ?? "tool"} (${update.kind ?? "other"})`;
progress(`\n[tool] ${lastTool}\n`);
} else if (update.sessionUpdate === "usage_update") {
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
}
if (Date.now() - lastProgressNote >= 60_000) {
lastProgressNote = Date.now();
void queue.update(row.qitemId, { note: `ACP run ${options.runId} progress: ${toolCalls} tool calls (latest ${lastTool || "none"}), ${text.length} characters of output so far` }).catch(() => {});
}
});
peer.onRequest("session/request_permission", async (params, requestId) => {
const request = params as { sessionId?: string; toolCall?: Record<string, unknown>; options?: PermissionOption[] };
if (request.sessionId !== sessionId) throw Object.assign(new Error(`no turn of session ${request.sessionId} is running here`), { code: -32602 });
const offered = request.options ?? [];
const toolCall = request.toolCall ?? {};
for (;;) {
if (turnOver || cancelRequestedAt) return { outcome: { outcome: "cancelled" } };
const askId = await queue.create({
destination: permissionsTo,
summary: `Permission for ${row.qitemId}: ${String(toolCall.title ?? toolCall.toolCallId ?? "a tool call")} (${String(toolCall.kind ?? "other")})`,
tags: ["acp-permission", ...(row.tags ?? []).filter((tag) => /^(project|mission|slice):/.test(tag))],
body: permissionBody(row.qitemId, options.runId, sessionId!, requestId, toolCall, offered, options.logPath),
});
await queue.update(row.qitemId, { note: `ACP request ${requestId} (session ${sessionId}): permission asked of ${permissionsTo} as ${askId}; the turn waits for that answer` });
progress(`\n[permission] asked ${permissionsTo} as ${askId}\n`);
for (;;) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
if (turnOver || cancelRequestedAt) {
await queue.update(askId, { state: "canceled", note: `Stale: the turn of ${row.qitemId} was cancelled before an answer; any answer here is not sent` }).catch(() => {});
return { outcome: { outcome: "cancelled" } };
}
const ask = await queue.show(askId);
// The answer point: a cancel that landed during the read above still wins, and nothing is awaited between here and the reply.
if (turnOver || cancelRequestedAt) {
const stale = `Stale: the turn of ${row.qitemId} was cancelled; ${FINAL_STATES.has(ask.state) ? "this answer was" : "any answer here is"} not sent`;
await queue.update(askId, FINAL_STATES.has(ask.state) ? { note: stale } : { state: "canceled", note: stale }).catch(() => {});
return { outcome: { outcome: "cancelled" } };
}
if (!FINAL_STATES.has(ask.state)) continue;
const chosen = chooseOption(offered, ask.resolution);
if (chosen) {
void queue.update(row.qitemId, { note: `ACP request ${requestId}: ${permissionsTo} answered ${chosen.optionId} on ${askId}; sent to the agent once` }).catch(() => {});
return { outcome: { outcome: "selected", optionId: chosen.optionId } };
}
await queue.update(row.qitemId, { note: `ACP request ${requestId}: ${askId} closed ${ask.state} without an offered option (${JSON.stringify(ask.resolution ?? null)}); asking again` });
break;
}
}
});
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
await queue.update(row.qitemId, {
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
});
return outcome;
};
try {
await spawned;
const init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig", version: "1" } }) as
{ protocolVersion?: number; agentCapabilities?: { loadSession?: boolean }; agentInfo?: { name?: string; version?: string } };
const loads = init.agentCapabilities?.loadSession === true;
if (options.loadSession) {
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
sessionId = options.loadSession;
} else {
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
}
const agent = `${init.agentInfo?.name ?? options.agent[0]} ${init.agentInfo?.version ?? "(no version reported)"}`;
await queue.update(row.qitemId, {
note: `ACP run ${options.runId}: executor ${agent} (protocol ${init.protocolVersion ?? "?"}, loadSession ${loads ? "advertised" : "not advertised"}), session ${sessionId} ${options.loadSession ? "loaded" : "new"}, custodian ${options.self}; transcript ${options.logPath}`,
});
void options.cancelRequested?.then(async () => {
if (turnOver || cancelRequestedAt) return;
cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId });
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
});
// A detach settles the turn here even when the agent keeps its stdout open after stdin closes.
const detached = new Promise<never>((_, reject) => {
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
});
detached.catch(() => {});
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
turnOver = true;
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
} catch (error) {
turnOver = true;
if (error instanceof JsonRpcRequestError) {
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
}
if (error instanceof DetachedBeforeTurnEnd) {
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
}
if (error instanceof JsonRpcTransportClosed) {
return await record({ kind: "unknown", sessionId, text, summary: "the agent's connection closed before the turn ended" });
}
return await record({ kind: "failed", sessionId, text, summary: `the agent couldn't be run: ${(error as Error).message}` });
} finally {
turnOver = true;
const exit = await peer.end(options.exitWaitMs ?? 5000);
const left = await findRunProcesses(options.runId).catch(() => undefined);
const settled = left === undefined ? "unknown (the process table couldn't be read)"
: left.length === 0 ? "settled: no process carrying this run's marker remains"
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
}
}
/** The stop reason alone doesn't say the work happened: a silent `end_turn` is unknown, not done. */
export function classify(stopReason: string | undefined, text: string, usage: { used?: number } | undefined, cancelRequestedAt: string | undefined, sessionId?: string): AcpOutcome {
if (stopReason === "cancelled") {
return { kind: "cancelled", sessionId, text, summary: `cancel ${cancelRequestedAt ? `requested at ${cancelRequestedAt}, ` : ""}acknowledged by the agent at ${new Date().toISOString()} (stopReason cancelled); upstream spend is unknown` };
}
if (stopReason !== "end_turn") return { kind: "stopped", sessionId, text, summary: `the agent stopped with ${stopReason ?? "no stop reason"}` };
const interruption = INTERRUPTION_MARKERS.find((marker) => text.includes(marker));
if (interruption) return { kind: "unknown", sessionId, text, summary: `the agent ended its turn but reported an interruption (${interruption}); not treated as done` };
if (!text.trim() && !(usage?.used && usage.used > 0)) {
return { kind: "unknown", sessionId, text, summary: `the agent ended its turn with no output and ${usage ? "zero" : "no"} usage reported; not treated as done` };
}
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
}
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
const value = answer?.trim();
if (!value) return undefined;
const byId = offered.find((option) => option.optionId === value);
if (byId) return byId;
const byKind = offered.filter((option) => option.kind === value);
return byKind.length === 1 ? byKind[0] : undefined;
}
function permissionBody(qitemId: string, runId: string, sessionId: string, requestId: string | number, toolCall: Record<string, unknown>, offered: PermissionOption[], logPath: string): string {
const input = toolCall.rawInput === undefined ? undefined : JSON.stringify(toolCall.rawInput);
const where = Array.isArray(toolCall.locations) ? (toolCall.locations as Array<{ path?: string }>).map((l) => l.path).filter(Boolean).join(", ") : "";
return [
`# The ACP agent working on ${qitemId} asks permission`,
"",
`- **Action:** ${String(toolCall.title ?? "(no title)")} (kind ${String(toolCall.kind ?? "other")}, tool call ${String(toolCall.toolCallId ?? "?")})`,
where ? `- **Target:** ${where}` : undefined,
input === undefined ? undefined : input.length > 2000 ? `- **Input:** \`${input.slice(0, 2000)}\` … (truncated; the full input is in ${logPath})` : `- **Input:** \`${input}\``,
`- **Asked by:** ACP run ${runId}, session ${sessionId}, request ${requestId}`,
"",
"**Options the agent offered:**",
...offered.map((option) => `- \`${option.optionId}\`: ${option.name ?? ""}${option.kind ? ` (${option.kind})` : ""}`),
"",
`Answer on this row with \`${answerCommand("<this row>")}\`; don't hand it off.`,
"The agent's own settings and policy decide what it asks; your answer goes back to this one request, once. The turn waits until you answer.",
].filter((line) => line !== undefined).join("\n");
}
+141
View File
@@ -0,0 +1,141 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
// a handful of methods, so swap to it only if the method set grows.
export interface JsonRpcErrorObject {
code: number;
message: string;
data?: unknown;
}
/** An error the agent returned, with its original code, message and data kept for the record. */
export class JsonRpcRequestError extends Error {
constructor(readonly method: string, readonly error: JsonRpcErrorObject) {
super(`${method} failed: ${error.code} ${error.message}`);
this.name = "JsonRpcRequestError";
}
}
/** The connection ended before the agent answered; the request's effect is unknown. */
export class JsonRpcTransportClosed extends Error {
constructor(readonly method: string) {
super(`${method}: the agent's connection closed before it answered`);
this.name = "JsonRpcTransportClosed";
}
}
export type JsonRpcId = number | string;
export type IncomingRequestHandler = (params: unknown, id: JsonRpcId) => Promise<unknown>;
export type NotificationHandler = (params: unknown) => void;
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
export class StdioJsonRpcPeer {
private nextId = 0;
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private ended = false;
private readonly lines: ReturnType<typeof createInterface>;
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input: child.stdout });
this.lines.on("line", (line) => this.receive(line));
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
request(method: string, params: unknown): Promise<unknown> {
if (this.ended) return Promise.reject(new JsonRpcTransportClosed(method));
const id = ++this.nextId;
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
this.send({ jsonrpc: "2.0", id, method, params });
});
}
notify(method: string, params: unknown): void {
if (!this.ended) this.send({ jsonrpc: "2.0", method, params });
}
onRequest(method: string, handler: IncomingRequestHandler): void {
this.requestHandlers.set(method, handler);
}
onNotification(method: string, handler: NotificationHandler): void {
this.notificationHandlers.set(method, handler);
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
private send(packet: Record<string, unknown>): void {
this.record("out", packet);
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
}
private receive(line: string): void {
if (!line.trim()) return;
let packet: Record<string, unknown>;
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
packet = parsed as Record<string, unknown>;
} catch {
this.record("in-text", line);
return;
}
this.record("in", packet);
const { id, method } = packet;
if (typeof method === "string" && (typeof id === "number" || typeof id === "string")) {
void this.answer(method, id, packet.params);
} else if (typeof method === "string") {
this.notificationHandlers.get(method)?.(packet.params);
} else if (typeof id === "number" || typeof id === "string") {
const request = this.pending.get(id);
if (!request) return; // a reply to nothing we asked; already recorded
this.pending.delete(id);
if (packet.error && typeof packet.error === "object") request.reject(new JsonRpcRequestError(request.method, packet.error as JsonRpcErrorObject));
else request.resolve(packet.result);
}
}
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
const handler = this.requestHandlers.get(method);
if (!handler) {
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
return;
}
try {
const result = await handler(params, id);
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
} catch (error) {
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { promisify } from "node:util";
/** Set in the agent's environment by `rig acp run`; every process it launches inherits it, even one that detaches
* (PPID 1), which is how a run finds what it started without touching anything it didn't. */
export const RUN_MARKER = "OPENRIG_ACP_RUN";
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
export interface RunProcess { pid: number }
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
const marker = `${RUN_MARKER}=${runId}`;
if (process.platform === "linux") {
const found: RunProcess[] = [];
for (const entry of await readdir("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const environ = await readFile(`/proc/${entry}/environ`, "utf8");
if (!environ.split("\0").includes(marker)) continue;
found.push({ pid: Number(entry) });
} catch { /* exited, or not ours to read */ }
}
return found;
}
const { stdout } = await promisify(execFile)("ps", ["axeww", "-o", "pid=,command="], { maxBuffer: 32 * 1024 * 1024 });
return stdout.split("\n").flatMap((line) => {
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
if (!match || !match[2]!.split(/\s+/).includes(marker)) return [];
return [{ pid: Number(match[1]) }];
});
}
+94 -31
View File
@@ -3,8 +3,9 @@ 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 } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } 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();
@@ -14,23 +15,33 @@ 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
* buggy/stale caller cannot write false history by fat-fingering or stale-copying a body field.
* Absent env ⇒ no header ⇒ the daemon DELIVERS and labels the write `claimed:v1` (P18 deliver-and-label;
* a write with no actor at all is 400 actor_required — parameter completeness, not a refusal). One
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand.
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
*/
export function senderIdentityHeaders(originSelfHostId?: string): Record<string, string> {
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
if (!session) return {};
// Preserve qualified senders; append only a locally derived instance id.
const value =
originSelfHostId && originSelfHostId.length > 0 && session.split("@").length < 3
? `${session}@${originSelfHostId}`
: session;
return { [SENDER_IDENTITY_HEADER]: value };
// 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 } : {}),
};
}
/** Construct registered remote clients with the origin derived from this instance's durable store. */
@@ -111,6 +122,7 @@ export class DaemonResponseError extends Error {
export interface DaemonResponse<T = unknown> {
status: number;
data: T;
headers?: Headers;
}
/**
@@ -135,7 +147,15 @@ 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. */
@@ -157,6 +177,7 @@ 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. */
@@ -164,43 +185,78 @@ export class DaemonClient {
remoteTarget = false;
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 localHeaders = senderIdentityHeaders();
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 localHeaders;
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (!this.remoteTarget && !directTarget) return localHeaders;
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
// 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 { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
}
if (!this.remoteTarget) {
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
if (target === origin) return localHeaders;
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
finally { clearTimeout(timer); controller.abort(); }
}
return senderIdentityHeaders(origin);
// 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" };
}
constructor(baseUrl?: string, options?: DaemonClientOptions) {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -217,8 +273,11 @@ 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. */
@@ -303,7 +362,7 @@ export class DaemonClient {
let responseStatus: number | undefined;
try {
const response = await fetchWithTimeout(
this.fetchImpl,
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
@@ -357,7 +416,11 @@ export class DaemonClient {
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
let data = "";
const res = await this.fetch(path, init, options, async (response) => { data = await response.text(); });
return { status: res.status, data };
let headers: Headers | undefined;
const res = await this.fetch(path, init, options, async (response) => {
headers = response.headers;
data = await response.text();
});
return { status: res.status, data, headers };
}
}
+175
View File
@@ -0,0 +1,175 @@
import { Command } from "commander";
import { execFileSync, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DaemonClient, type DaemonResponse } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { processIdentity } from "../local-daemon-owner.js";
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
import { findRunProcesses } from "../acp/run-processes.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
/** The connector's queue verbs over the daemon API, acting as the calling seat (the client stamps its session). */
export function daemonAcpQueue(client: DaemonClient): AcpQueue {
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
return response.data;
};
return {
async show(qitemId) {
const data = ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`);
const row = (data.item ?? data) as Record<string, unknown>;
return {
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
body: typeof row.body === "string" ? row.body : "", resolution: (row.resolution as string | null) ?? null, tags: (row.tags as string[] | null) ?? null,
};
},
async claim(qitemId) { ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), `queue claim ${qitemId}`); },
async update(qitemId, change) {
ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, { transitionNote: change.note, state: change.state }), `queue update ${qitemId}`);
},
async create(row) {
const qitemId = newId("qitem", 8);
ok(await client.post("/api/queue/create", { qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags }), `queue create for ${row.destination}`);
return qitemId;
},
};
}
function readRun(runId: string): RunFile | undefined {
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
}
/** A process's start time, the discriminator the CLI already uses for daemon ownership (pids get reused). `ps` from PATH,
* as the other process readers use it: the setuid /bin/ps can't run in a sandbox (macOS CI runs a plain copy). */
export function processStart(pid: number): string | undefined {
try {
return processIdentity(execFileSync("ps", ["-o", "uid=", "-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } }))?.start;
} catch { return undefined; }
}
/** The run is live only while it hasn't ended and its pid is still the process that started it. */
function liveConnector(run: RunFile): boolean {
return !run.endedAt && run.processStart !== undefined && processStart(run.pid) === run.processStart;
}
export function acpCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const connect = async (): Promise<DaemonClient | undefined> => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
return daemonStatusGuard(status) ? deps.clientFactory(getDaemonUrl(status)) : undefined;
};
const self = () => readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
cmd
.command("run <qitemId> <agent...>")
.usage("<qitemId> [options] -- <agent program> [args...]")
.description("Send one row's task to an ACP agent as a single turn, and record its outcome on that row")
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
.option("--load-session <id>", "Continue an earlier session; if the agent can't load it, nothing new is started")
.option("--poll-seconds <n>", "How often to look for a permission answer", "2")
.addHelpText("after", `
Runs as this seat, the row's custodian; the agent is recorded as executor. The row's notes get the binding
(agent, session), progress, each permission request and its answer, and the outcome: completed, stopped,
cancelled, failed or unknown. The outcome is the agent's report, not a judgment of the work: the row stays open
for you to judge, then close or hand on with your own resolution.
A permission request becomes a row to --permissions-to, listing the options the agent offered. Answer it with
${answerCommand("<that row>")}
on that row; don't hand it off. OpenRig holds no policy of its own: the agent's settings decide what it
asks, and the owner decides the answer.
Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second Ctrl-C detaches without waiting.
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
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
const seat = self();
if (!seat) { console.error("rig acp run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
const client = await connect();
if (!client) return;
const runId = newId("acp", 4);
mkdirSync(runsDir(), { recursive: true });
const runFile: RunFile = { runId, qitemId, pid: process.pid, processStart: processStart(process.pid), agent, startedAt: new Date().toISOString() };
const writeRun = (run: RunFile) => writeFileSync(join(runsDir(), `${runId}.json`), `${JSON.stringify(run)}\n`);
writeRun(runFile);
let cancel!: () => void;
let detach!: () => void;
const cancelRequested = new Promise<void>((done) => { cancel = done; });
const detachRequested = new Promise<void>((done) => { detach = done; });
let interrupts = 0;
const onInterrupt = () => {
interrupts += 1;
if (interrupts === 1) { console.error("\n[rig acp] cancel requested; Ctrl-C again to detach without waiting"); cancel(); } else detach();
};
process.on("SIGINT", onInterrupt);
console.error(`[rig acp] run ${runId} for ${qitemId}; cancel with Ctrl-C or rig acp cancel ${runId}`);
try {
const outcome = await runAcpAssignment(daemonAcpQueue(client), {
qitemId, agent, cwd: resolve(opts.cwd), self: seat, permissionsTo: opts.permissionsTo, loadSession: opts.loadSession,
runId, logPath: join(runsDir(), `${runId}.jsonl`), pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
});
console.log(`\n[rig acp] ${outcome.kind}: ${outcome.summary}`);
if (outcome.kind !== "completed") process.exitCode = 2;
} catch (error) {
console.error(`rig acp run: ${(error as Error).message}`);
process.exitCode = 1;
} finally {
process.removeListener("SIGINT", onInterrupt);
writeRun({ ...runFile, endedAt: new Date().toISOString() });
}
});
cmd
.command("cancel <runId>")
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
.action((runId: string) => {
const run = readRun(runId);
if (!run || !liveConnector(run)) { console.error(`No running rig acp run ${runId}`); process.exitCode = 1; return; }
process.kill(run.pid, "SIGINT");
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges`);
});
cmd
.command("stop <runId> [stopCommand...]")
.usage("<runId> [-- <the runtime's own stop command> [args...]]")
.description("Run the agent runtime's own stop command, if given, then report whether every process the run launched has exited")
.addHelpText("after", `
rig acp never kills a process it didn't launch. A gateway the agent connects to but didn't start (OpenClaw's, or a
shared one) is left alone; stop it with its own command, if it's yours to stop. The result goes on the run's row.`)
.action(async (runId: string, stopCommand: string[]) => {
const run = readRun(runId);
if (!run) { console.error(`No rig acp run ${runId} on this machine`); process.exitCode = 1; return; }
if (liveConnector(run)) { console.error(`${runId} is still running; cancel it or detach first`); process.exitCode = 1; return; }
let ran = "no stop command was given";
if (stopCommand.length > 0) {
const result = spawnSync(stopCommand[0]!, stopCommand.slice(1), { stdio: "inherit" });
ran = `${stopCommand.join(" ")} exited ${result.error ? `with ${result.error.message}` : result.status ?? result.signal}`;
}
const left = await findRunProcesses(runId).catch(() => undefined);
const report = left === undefined ? "unknown: the process table couldn't be read"
: left.length === 0 ? `settled: no process from ${runId} remains`
: `incomplete: ${left.length} process(es) from ${runId} still run (${left.map((p) => p.pid).join(", ")}); they were not stopped`;
console.log(`${ran}; ${report}`);
if (left?.length !== 0) process.exitCode = 2;
const client = self() ? await connect() : undefined;
await client?.post(`/api/queue/${encodeURIComponent(run.qitemId)}/update`, { transitionNote: `ACP run ${runId} stop: ${ran}; ${report}` }).catch(() => {});
});
return cmd;
}
+11 -10
View File
@@ -13,6 +13,11 @@ 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 } {
@@ -22,15 +27,11 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
}
try {
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,
};
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! };
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
@@ -59,7 +60,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.action(async (opts: {
self?: boolean;
rig: string;
+41 -11
View File
@@ -17,6 +17,8 @@ import {
SEAT_REGISTRY_DISCLAIMER,
type CodexAuthPaths,
} from "../lib/codex-auth.js";
import { localSelfHostId, originFor, runOnSshHost, type RunOnHostDeps } from "../lib/run-on-host.js";
import { realDeps } from "./daemon.js";
export interface AuthCommandDeps {
/** env source (default process.env) — tests point CODEX_HOME at a fixture. */
@@ -25,6 +27,8 @@ export interface AuthCommandDeps {
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
/** clock for seats set updated_ts (default new Date().toISOString()). */
now?: () => string;
/** `--host`: the host registry and the SSH runner (tests inject both). */
host?: RunOnHostDeps;
}
function fail(reason: string): void {
@@ -44,18 +48,34 @@ function ensureRuntime(runtime: string): boolean {
export function authCommand(depsOverride?: AuthCommandDeps): Command {
const env = depsOverride?.env ?? process.env;
const paths = (): CodexAuthPaths => resolveCodexHome(env);
const paths = (codexHome?: string): CodexAuthPaths => resolveCodexHome(codexHome ? { ...env, CODEX_HOME: codexHome } : env);
const withRuntime = (c: Command): Command => c.option("--runtime <runtime>", "Runtime axis (MVP: codex)", "codex");
// Slice 18: the commands that move a Codex home act on a named home, here or on another registered machine.
const withHome = (c: Command): Command => c
.option("--codex-home <dir>", "The Codex home to act on (default: CODEX_HOME, else ~/.codex)")
.option("--host <id>", "Run it on this registered machine instead, over its SSH entry, as this seat");
type HomeOpts = { runtime: string; codexHome?: string; host?: string };
const onHost = async (verb: string, args: string[], opts: HomeOpts) => {
const seat = (env.OPENRIG_SESSION_NAME ?? env.RIGGED_SESSION_NAME)?.trim() || undefined;
const origin = await originFor(seat, depsOverride?.host?.selfHostId ?? (() => localSelfHostId(realDeps())));
const home = opts.codexHome ? ["--codex-home", opts.codexHome] : [];
await runOnSshHost(opts.host!, `auth ${verb}`, ["auth", verb, ...args, "--runtime", opts.runtime, ...home], {
origin,
// A switch changes that home; status and list only read.
...(verb === "switch" ? { changes: { check: `rig auth status --host ${opts.host} --codex-home ${opts.codexHome} (a switch copies the same saved profile again if repeated)` } } : {}),
}, depsOverride?.host);
};
const auth = new Command("auth").description(
"Manage agent auth profiles (CLI-local; runtime via --runtime). Tokens are never printed, logged, or stored.",
);
withRuntime(auth.command("status"))
withHome(withRuntime(auth.command("status")))
.description("Auth-file presence + login state (no secrets).")
.action((opts: { runtime: string }) => {
.action(async (opts: HomeOpts) => {
if (!ensureRuntime(opts.runtime)) return;
const r = authStatus(paths(), { loginStatus: depsOverride?.loginStatus });
if (opts.host) return onHost("status", [], opts);
const r = authStatus(paths(opts.codexHome), { loginStatus: depsOverride?.loginStatus });
console.log(`codex_home: ${r.codexHome}`);
console.log(`codex_home_present: ${r.codexHomePresent ? "yes" : "no"}`);
console.log(`active_auth_present: ${r.activeAuthPresent ? "yes" : "no"}`);
@@ -65,11 +85,12 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
console.log(`login_status: ${r.loginStatus}`);
});
withRuntime(auth.command("list"))
withHome(withRuntime(auth.command("list")))
.description("List saved profiles by name.")
.action((opts: { runtime: string }) => {
.action(async (opts: HomeOpts) => {
if (!ensureRuntime(opts.runtime)) return;
for (const name of authList(paths())) console.log(name);
if (opts.host) return onHost("list", [], opts);
for (const name of authList(paths(opts.codexHome))) console.log(name);
});
withRuntime(auth.command("save <profile>"))
@@ -83,15 +104,24 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
console.log(`profile_mode: ${r.mode}`);
});
withRuntime(auth.command("switch <profile>"))
.description("Activate a saved profile.")
.action((profile: string, opts: { runtime: string }) => {
withHome(withRuntime(auth.command("switch <profile>")))
.description("Restore saved credential bytes (sign-in is unverified).")
.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
cross the connection.`)
.action(async (profile: string, opts: HomeOpts) => {
if (!ensureRuntime(opts.runtime)) return;
const r = authSwitch(paths(), profile);
if (opts.host && !opts.codexHome) {
return fail(`switch --host needs --codex-home: name the home on ${opts.host} (rig auth status --host ${opts.host} shows the home it would use)`);
}
if (opts.host) return onHost("switch", [profile], opts);
const r = authSwitch(paths(opts.codexHome), profile);
if (!r.ok) return fail(r.reason);
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}`);
});
+78 -5
View File
@@ -26,6 +26,8 @@ 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. */
@@ -44,6 +46,30 @@ 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.
@@ -89,6 +115,17 @@ 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
@@ -127,6 +164,8 @@ 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", `
@@ -134,6 +173,7 @@ 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
@@ -146,6 +186,17 @@ 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
@@ -178,6 +229,12 @@ 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
@@ -208,14 +265,18 @@ 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: isNaN(lines) ? 20 : lines };
const body: Record<string, unknown> = { lines: requestedLines };
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() });
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body,
{ headers: terminalAuthHeaders(), ...(size ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {}) });
if (res.status < 400) res.data = withExactLines(res.data, opts.exactLines);
if (opts.json) {
@@ -275,8 +336,16 @@ 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.
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
// 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.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);
@@ -314,13 +383,17 @@ async function runHttpHostCapture(
hint?: string,
): Promise<void> {
const lines = parseInt(opts.lines ?? "20", 10);
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: requestedLines };
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, {});
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps,
body.readable ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {});
if (result.ok) result.data = withExactLines(result.data, opts.exactLines);
if (opts.json) {
+29 -4
View File
@@ -4,7 +4,24 @@ 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 { execSync } from "node:child_process";
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;
}
interface RigSummary {
id: string;
@@ -53,6 +70,7 @@ 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")
@@ -91,6 +109,7 @@ 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")
@@ -154,6 +173,7 @@ 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 }) => {
@@ -172,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.tmux) {
const sessionName = `chatroom@${rig}`;
try {
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}`);
// 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}`);
} catch {
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
process.exitCode = 1;
@@ -235,6 +257,7 @@ 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")
@@ -270,6 +293,7 @@ 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")
@@ -382,6 +406,7 @@ 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();
+60 -3
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { ConfigStore } from "../config-store.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { daemonMayAnswer } from "../daemon-reachability.js";
@@ -7,6 +8,8 @@ import { realDeps } from "./daemon.js";
export interface CompactPlanDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
/** Config file that holds the automatic compaction setting; defaults to the rig config. */
configPath?: string;
}
interface RigEntry {
@@ -53,6 +56,12 @@ interface CompactPlanThresholds {
thresholdPercent: number;
}
interface AutomaticCompactionSetting {
enabled: boolean | null;
thresholdPercent: number | null;
unavailableReason: string | null;
}
interface CompactPlanSeatPolicy {
oneSeatAtATime: true;
autoCompactAllowed: false;
@@ -111,8 +120,16 @@ interface CompactPlanResult {
mode: "read_only_plan";
defaultThresholdTokens: number;
defaultThresholdPercent: number;
/** The plan's own token threshold; not the automatic compaction setting. */
thresholdTokens: number;
/** The plan's own fallback percentage; not the automatic compaction setting. */
thresholdPercent: number;
/** Whether `policies.claude_compaction.enabled` is on; null when the config could not be read. */
automaticCompactionEnabled: boolean | null;
/** `policies.claude_compaction.threshold_percent`, shown only for comparison; null when the config could not be read. */
automaticCompactionThresholdPercent: number | null;
/** Why the automatic compaction setting is unknown; null when it was read. */
automaticCompactionUnavailableReason: string | null;
oneSeatAtATime: true;
autoCompactAllowed: false;
explicitAuthorizationRequired: true;
@@ -303,7 +320,12 @@ function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): Candid
};
}
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompleteRigs: IncompleteRig[] = []): CompactPlanResult {
function buildPlan(
nodes: NodeEntry[],
thresholds: CompactPlanThresholds,
incompleteRigs: IncompleteRig[],
automaticCompaction: AutomaticCompactionSetting,
): CompactPlanResult {
const candidates: CandidateEntry[] = [];
const blocked: BlockedEntry[] = [];
const skipped: SkippedEntry[] = [];
@@ -337,6 +359,9 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompl
defaultThresholdPercent: PERCENT_FALLBACK_THRESHOLD,
thresholdTokens: thresholds.thresholdTokens,
thresholdPercent: thresholds.thresholdPercent,
automaticCompactionEnabled: automaticCompaction.enabled,
automaticCompactionThresholdPercent: automaticCompaction.thresholdPercent,
automaticCompactionUnavailableReason: automaticCompaction.unavailableReason,
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
@@ -350,13 +375,27 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompl
};
}
function automaticCompactionSummary(policy: CompactPlanResult["policy"]): string {
const enabled = policy.automaticCompactionEnabled;
const thresholdPercent = policy.automaticCompactionThresholdPercent;
if (enabled == null || thresholdPercent == null) {
const reason = policy.automaticCompactionUnavailableReason;
return `Automatic compaction: couldn't be read${reason ? `: ${reason}` : ""}; run \`rig config\` to check it.`;
}
if (enabled) {
return `Automatic compaction: on, at ${thresholdPercent}% (policies.claude_compaction.threshold_percent).`;
}
return `Automatic compaction: off (its threshold setting policies.claude_compaction.threshold_percent is ${thresholdPercent}%).`;
}
function printHuman(plan: CompactPlanResult): void {
console.log("READ-ONLY PLAN - does not compact");
if (plan.incompleteRigs.length > 0) {
console.error(`Warning: partial plan; incomplete rigs: ${plan.incompleteRigs.map(({ rig, status }) => `${rig} (${typeof status === "number" ? `HTTP ${status}` : status})`).join(", ")}`);
}
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(`Plan thresholds (this plan's own, not the automatic compaction setting): ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(automaticCompactionSummary(plan.policy));
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
console.log();
@@ -419,6 +458,24 @@ Examples:
return;
}
let automaticCompaction: AutomaticCompactionSetting;
try {
const config = new ConfigStore(deps.configPath).resolve();
automaticCompaction = {
enabled: config.policies.claudeCompaction.enabled,
thresholdPercent: config.policies.claudeCompaction.thresholdPercent,
unavailableReason: null,
};
} catch (err) {
// The plan is still useful without the setting; report it as unknown rather than
// showing a default the person may not have configured, and keep the plan.
automaticCompaction = {
enabled: null,
thresholdPercent: null,
unavailableReason: err instanceof Error ? err.message : String(err),
};
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonMayAnswer(status)) {
console.error("Daemon is not running. Start it with: rig daemon start");
@@ -489,7 +546,7 @@ Examples:
}
}
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs);
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs, automaticCompaction);
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
+1 -1
View File
@@ -101,7 +101,7 @@ already submitted. Raw provider /compact and normal messages are unchanged.`);
const stage = res.data["stage"] as string | undefined;
const compact = res.data["compact"] as string | undefined;
console.log(`Manual compaction for ${session}: ${compact ?? "submitted, unconfirmed"}${stage ? ` (stage: ${stage})` : ""}`);
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold, unless the daemon restarts first.");
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold, and a daemon restart picks them up where they stopped (rig compact --state shows one too old to resume).");
});
return cmd;
+7 -1
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "db.path"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -70,6 +70,7 @@ Keys:
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
health.context_pressure.* context-pressure warning/critical percentages (defaults 95/99)
files.allowlist name:/abs/path,name:/abs/path
@@ -113,10 +114,15 @@ Keys:
output.whoami_peers with output.compact on, how many peers rig whoami --json lists, edge peers first; --all-peers lists all (default 8)
bundle.services_enabled Service-backed bundle packing, preview and target installs (default off)
provider.usage_windows Codex and Claude usage windows in rig provider status (Codex read on request; Claude from its seats' status lines) (default off)
provider.selected the providers chosen at rig setup --providers (claude, codex or claude,codex); kernel boot uses only these (default empty: every signed-in provider)
usage.pi_cost_facts Pi seats report model-priced cost from their persisted session entries to rig usage cost (default off)
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
decisions.template_dir absolute folder of <name>.yaml templates that replace the shipped ones (default empty: shipped templates only)
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
+25 -24
View File
@@ -113,13 +113,8 @@ interface PreviewWire {
missingFiles: Array<{ path: string; role: string }>;
}
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`);
}
}
/** 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"]);
function assertLocalGitClient(client: DaemonClient): void {
if (!["127.0.0.1", "localhost", "[::1]"].includes(new URL(client.baseUrl).hostname)) {
@@ -681,6 +676,7 @@ 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
@@ -782,7 +778,8 @@ 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.
// 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.
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)")
@@ -790,29 +787,33 @@ Examples:
.requiredOption("--file <path>", "Markdown file containing the recap content")
.action(async (opts: { rig: string; seat: string; file: string }) => {
try {
const { writeSeatRecap, validateRecapContract, listRecapChain } = await import("@openrig/daemon/seat-recap-store");
const { writeSeatRecapAt, validateRecapContract } = await import("@openrig/daemon/seat-recap-store");
const content = readFileSync(opts.file, "utf-8");
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 });
const topologyRoot = String(new ConfigStore().resolveWithSource("topology.root").value);
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.`);
}
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)` : ""}`);
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}` : ""));
} catch (err) {
console.error((err as Error).message);
// 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);
process.exitCode = 1;
}
});
+11 -5
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 } from "../openrig-compat.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
@@ -54,6 +54,14 @@ 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
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
async function realEmit(): Promise<CrashCartEmit> {
const cc = await import("@openrig/daemon/crash-cart");
const home = getOpenRigHome();
const openrigUrl = process.env.OPENRIG_URL;
const openrigUrl = readOpenRigUrl();
const probeClassified = (url: string) =>
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
const detectDeps = {
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
? `alive (pid ${state.pid})`
: `dead (pid ${state.pid})`
: "no daemon.json";
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const url = crashCartEvidenceUrl(openrigUrl, state);
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+2 -2
View File
@@ -42,7 +42,7 @@ export function createCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
let agentRef: string;
try {
agentRef = await resolveDefaultAgentRef(client);
agentRef = await resolveDefaultAgentRef(client, opts.runtime);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
@@ -61,7 +61,7 @@ export function createCommand(depsOverride?: StatusDeps): Command {
id: "lead",
agent_ref: agentRef,
runtime: opts.runtime,
profile: "default",
profile: opts.runtime === "terminal" ? "none" : "default",
cwd,
}],
edges: [],
+8 -2
View File
@@ -167,6 +167,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Host to bind on")
.option("--db <path>", "Database path")
.option("--allow-db-conflict", "Proceed for this start when another live daemon is confirmed using the database")
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
// path. Used by test fixtures, headless CI, and operators who want
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
@@ -181,7 +182,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// weaker "daemon-ready". Default 60s; override with --wait-for-kernel-ms.
.option("--wait-for-kernel", "After daemon binds, also wait for kernel-agent readiness (default timeout 60s)")
.option("--wait-for-kernel-ms <ms>", "Override --wait-for-kernel timeout in milliseconds")
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
.action(async (opts: { port?: string; host?: string; db?: string; allowDbConflict?: boolean; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight, quietPreflightExec } = await import("../system-preflight.js");
@@ -251,6 +252,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
// check in startup.ts honors the flag.
skipKernelBoot: skipKernel,
allowDbConflict: opts.allowDbConflict,
},
getDeps(),
);
@@ -262,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 } = await import("../daemon-lifecycle.js");
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
@@ -270,6 +272,10 @@ 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(
+20 -3
View File
@@ -13,7 +13,7 @@ import {
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, 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,7 +31,8 @@ export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
exec: (cmd: string) => string;
/** `timeoutMs` bounds one command; without it a command runs to completion, as before. */
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
checkPort: (port: number, host: string) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
@@ -128,6 +129,16 @@ 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") {
@@ -384,6 +395,12 @@ 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();
@@ -427,7 +444,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: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
exec: doctorExec,
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
+115 -7
View File
@@ -6,6 +6,75 @@ import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { resolveRigHandle } from "../rig-handle.js";
import { shellQuote } from "../cross-host-executor.js";
import { resolveIdentitySource } from "./whoami.js";
interface DownOptions {
delete?: boolean;
force?: boolean;
snapshot?: boolean;
yes?: boolean;
json?: boolean;
host?: string;
}
interface DownDeps extends StatusDeps {
promptYesNo?: (question: string) => Promise<boolean>;
}
interface OwnRigWarning {
fact: string;
consequence: string;
action: string;
}
async function confirmOwnRig(
rigId: string,
opts: DownOptions,
deps: DownDeps,
readIdentity: (path: string) => Promise<unknown>,
): Promise<OwnRigWarning | false | undefined> {
const source = resolveIdentitySource({});
if (!source) return;
// A local session name can identify a different seat on the selected host.
if (opts.host && !source.nodeId) return;
const params = new URLSearchParams(source.nodeId ? { nodeId: source.nodeId } : { sessionName: source.sessionName! });
params.set("compact", "1");
let caller: { identity?: { rigId?: string; attachmentType?: string } } | undefined;
try { caller = await readIdentity(`/api/whoami?${params}`) as typeof caller; }
catch { return; } // An unresolved caller is not proof of membership; keep the existing teardown path.
if (caller?.identity?.rigId !== rigId) return;
const warning: OwnRigWarning = {
fact: `Your current seat belongs to rig ${rigId}.`,
consequence: caller.identity.attachmentType === "external_cli"
? "OpenRig doesn't stop externally attached sessions. This stops the rig's managed sessions and their work."
: "This ends your own session and stops the rig's other managed seats and their work. OpenRig doesn't stop externally attached sessions.",
action: ["rig", "down", shellQuote(rigId), "--yes", ...(opts.delete ? ["--delete"] : []),
...(opts.force ? ["--force"] : []), ...(opts.snapshot ? ["--snapshot"] : []),
...(opts.json ? ["--json"] : []), ...(opts.host ? ["--host", shellQuote(opts.host)] : [])].join(" "),
};
if (!opts.json) console.error(`Warning: ${warning.fact} ${warning.consequence}`);
if (opts.yes) return warning;
if (deps.promptYesNo || (process.stdin.isTTY && process.stderr.isTTY)) {
const question = `${opts.json ? `${warning.fact} ${warning.consequence}\n` : ""}Stop this rig? [y/N] `;
let accepted: boolean;
if (deps.promptYesNo) accepted = await deps.promptYesNo(question);
else {
const { createInterface } = await import("node:readline");
const rl = createInterface({ input: process.stdin, output: process.stderr });
accepted = await new Promise<boolean>(resolve => {
rl.once("close", () => resolve(false));
rl.question(question, answer => { resolve(/^y(es)?$/i.test(answer.trim())); rl.close(); });
});
}
if (accepted) return warning;
}
if (opts.json) console.log(JSON.stringify({ rigId, error: { code: "own_rig_confirmation_required", ...warning } }));
else console.error(`Nothing stopped. To proceed: ${warning.action}`);
process.exitCode = 1;
return false;
}
interface TeardownResult {
rigId: string;
@@ -15,6 +84,13 @@ interface TeardownResult {
deleteBlocked: boolean;
alreadyStopped: boolean;
errors: string[];
/** Team skill files kept in seats' own storage: edited, or never recorded as OpenRig's. */
keptSkills?: string[];
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
keptGuidance?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
}
interface DownAgentEntry {
@@ -33,8 +109,10 @@ 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 agents = (inventory as DownAgentEntry[]).filter(n => n.nodeKind === "agent"
&& ["running", "idle", "unknown"].includes(n.sessionStatus ?? ""));
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);
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.
@@ -46,6 +124,7 @@ 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}.`);
@@ -58,14 +137,16 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
* @param depsOverride - injectable deps for testing
* @returns Commander command
*/
export function downCommand(depsOverride?: StatusDeps): Command {
export function downCommand(depsOverride?: DownDeps): Command {
const cmd = new Command("down").description("Tear down a rig")
.addHelpText("after", `
rig down stops every agent in the rig: their sessions end, and any work in progress stops with them.
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
--delete the rig record is removed too, so it can't be brought back that way.`);
--delete the rig record is removed too, so it can't be brought back that way.
When your resolved seat belongs to this rig, confirm before stopping it. Without a terminal,
the command warns and exits without stopping; re-run with --yes to confirm explicitly.`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
@@ -73,9 +154,10 @@ rig up <rig> --existing brings it back, resuming each seat's conversation where
.option("--delete", "Delete rig record after stopping")
.option("--force", "Kill sessions immediately")
.option("--snapshot", "Take snapshot before teardown")
.option("--yes", "Confirm stopping the rig your current seat belongs to")
.option("--json", "JSON output for agents")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean; host?: string }) => {
.action(async (rigHandle: string, opts: DownOptions) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly.
@@ -97,9 +179,14 @@ rig up <rig> --existing brings it back, resuming each seat's conversation where
return inventory.ok ? inventory.data : undefined;
});
}
const warning = await confirmOwnRig(rigIdResult.rigId, opts, deps, async path => {
const identity = await runRemoteHttpOp(opts.host!, "GET", path, undefined, deps, { timeoutMs: 2_000 });
return identity.ok ? identity.data : undefined;
});
if (warning === false) return;
const result = await runRemoteHttpOp(opts.host, "POST", `/api/down`, { rigId: rigIdResult.rigId, delete: opts.delete, force: opts.force, snapshot: opts.snapshot }, deps, opts);
if (opts.json) {
console.log(JSON.stringify(result));
console.log(JSON.stringify(warning ? { ...result, warning } : result));
if (!result.ok) process.exitCode = 1;
} else if (result.ok) {
console.log(JSON.stringify(result.data, null, 2));
@@ -166,6 +253,11 @@ rig up <rig> --existing brings it back, resuming each seat's conversation where
});
}
const warning = await confirmOwnRig(rigId, opts, deps, async path => {
const identity = await client.get<unknown>(path, { timeoutMs: 2_000 });
return identity.status === 200 ? identity.data : undefined;
});
if (warning === false) return;
const res = await client.post<TeardownResult | { error: string }>("/api/down", {
rigId,
delete: opts.delete ?? false,
@@ -174,7 +266,7 @@ rig up <rig> --existing brings it back, resuming each seat's conversation where
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
console.log(JSON.stringify(warning ? { ...res.data, warning } : res.data));
if (res.status >= 400) {
process.exitCode = 2;
} else {
@@ -194,6 +286,22 @@ rig up <rig> --existing brings it back, resuming each seat's conversation where
}
const result = res.data as TeardownResult;
// OPR.0.7.0.13: say what was kept and why, whatever else happened.
if (result.keptSkills && result.keptSkills.length > 0) {
console.log(`Kept ${result.keptSkills.length} team skill file(s) in the seats' own storage: edited, or not recorded as OpenRig's:`);
for (const file of result.keptSkills) console.log(` ${file}`);
}
if (result.keptGuidance && result.keptGuidance.length > 0) {
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
for (const block of result.keptGuidance) console.log(` ${block}`);
}
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) {
+169
View File
@@ -0,0 +1,169 @@
import { Command } from "commander";
import { DaemonClient, DaemonResponseError, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { readSelectedHost } from "../host-selection.js";
import { resolveRigHandle } from "../rig-handle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface EdgeEnd {
logicalId: string;
nodeId: string | null;
}
interface EdgeSuccess {
ok: true;
rigId: string;
outcome: "would_add" | "added" | "present" | "would_remove" | "removed";
edge: { id?: string; kind: string; from: EdgeEnd; to: EdgeEnd };
rollback?: string;
routing?: {
orchestrator?: { seat: string; from: string | null; to: string | null };
representative?: { from: string | null; to: string | null };
};
}
type EdgeOpResult =
| EdgeSuccess
| { ok: false; code: string; message: string; candidates?: string[] };
const NO_EDGE_ROUTE = "This daemon has no edge route. Upgrade it to a version with `rig edge`, then retry.";
function report(result: EdgeOpResult, opts: { json?: boolean }): void {
if (opts.json) console.log(JSON.stringify(result, null, 2));
else if (!result.ok) console.error(`${result.code}: ${result.message}`);
else {
const { edge } = result;
const line = `${edge.kind} ${edge.from.logicalId} -> ${edge.to.logicalId}`;
const id = edge.id ? ` edge ${edge.id}` : "";
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(" nothing written (--plan)");
break;
case "added":
console.log(`added ${line}${id}`);
console.log(` rollback: ${result.rollback}`);
break;
case "present":
console.log(`present ${line}${id} (already there; nothing written)`);
break;
case "would_remove":
console.log(`plan remove ${line}${id} from rig ${result.rigId}`);
console.log(" nothing written (--plan)");
break;
case "removed":
console.log(`removed ${line}${id}`);
break;
}
const { orchestrator, representative } = result.routing ?? {};
if (orchestrator) {
console.log(` routing: ${orchestrator.seat}'s orchestrator (its stuck-row findings and recovery) is now ${orchestrator.to ?? "none"}, was ${orchestrator.from ?? "none"}`);
}
if (representative) {
console.log(` routing: the rig's representative (where a person's Slack message in the rig's mapped channel goes, unless that channel names an inbound seat) is now ${representative.to ?? "none, so such a message goes to Slack's inbound destination"}, was ${representative.from ?? "none"}`);
}
if (result.outcome === "added" || result.outcome === "removed") {
console.log(` capture this topology with \`rig snapshot ${result.rigId}\`; restore uses the selected snapshot`);
}
}
if (!result.ok) process.exitCode = 1;
}
export function edgeCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("edge").description("Add or remove typed edges between existing seats");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const run = async (
rigHandle: string,
opts: { json?: boolean },
send: (client: DaemonClient, rigId: string) => Promise<DaemonResponse<EdgeOpResult>>,
) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status, { json: opts.json })) return;
const client = deps.clientFactory(getDaemonUrl(status));
const resolution = await resolveRigHandle(client, rigHandle);
const selectedHost = readSelectedHost();
// Match rig add: names copied from a selected remote must not silently
// mutate a same-named rig on the local daemon. Explicit local IDs still work.
if (selectedHost !== "local" && (resolution.kind === "ambiguous" || (resolution.kind === "resolved" && resolution.byName))) {
report({
ok: false, code: "remote_host_selected",
message: `Host '${selectedHost}' is selected, but rig edge only targets the local daemon. Run it on that host or pass an explicit local rig ID (rig host select local; rig ps --json).`,
}, opts);
return;
}
if (resolution.kind === "ambiguous") {
report({
ok: false, code: "rig_ambiguous", candidates: resolution.ids,
message: `'${rigHandle}' matches ${resolution.ids.length} rigs. Re-run with one of these IDs: ${resolution.ids.join(", ")}.`,
}, opts);
return;
}
if (resolution.kind === "not_found") {
report({ ok: false, code: "rig_not_found", message: `rig '${rigHandle}' not found; list active rigs with rig ps --json, or use an archived rig's exact ID` }, opts);
return;
}
const rigId = resolution.kind === "resolved" ? resolution.id : rigHandle;
let result: EdgeOpResult;
try {
const response = await send(client, rigId);
result = response.data;
// Older daemons have used both JSON not_found and plain-text 404 pages.
if (response.status === 404 && (!result || !("code" in result))) {
result = { ok: false, code: "edge_route_missing", message: NO_EDGE_ROUTE };
}
} catch (err) {
if (!(err instanceof DaemonResponseError) || err.status !== 404) throw err;
result = { ok: false, code: "edge_route_missing", message: NO_EDGE_ROUTE };
}
report(result, opts);
};
cmd
.command("add")
.description("Ensure one typed edge; 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")
.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")
.addHelpText("after", `
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.
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 }) => {
if (!from || !to || !opts.kind) {
report({ ok: false, code: "validation_failed", message: "Supply from, to and --kind" }, opts);
return;
}
const body = { from, to, kind: opts.kind, plan: opts.plan === true };
await run(rig, opts, (client, rigId) => client.post<EdgeOpResult>(`/api/rigs/${encodeURIComponent(rigId)}/edges`, body));
});
cmd
.command("remove")
.description("Remove exactly one edge, by the ID that rig edge add returned")
.argument("<rig>", "Target rig name or ID")
.argument("<edge-id>", "Edge ID")
.option("--plan", "Show what would change and write nothing")
.option("--json", "JSON output for agents")
.action((rig: string, edgeId: string, opts: { plan?: boolean; json?: boolean }) =>
run(rig, opts, (client, rigId) => client.delete<EdgeOpResult>(
`/api/rigs/${encodeURIComponent(rigId)}/edges/${encodeURIComponent(edgeId)}${opts.plan ? "?plan=1" : ""}`,
)));
return cmd;
}
+3
View File
@@ -38,6 +38,7 @@ 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 }) => {
@@ -94,6 +95,7 @@ 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")
@@ -126,6 +128,7 @@ 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 }) => {
+108
View File
@@ -1,3 +1,5 @@
import nodePath from "node:path";
import fs from "node:fs";
import { Command } from "commander";
import { writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
@@ -8,6 +10,31 @@ import { rigIdForHandle } from "../rig-handle.js";
export interface ExportDeps extends StatusDeps {
writeFile: (path: string, content: string) => void;
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 {
@@ -16,6 +43,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
exists: (p) => fs.existsSync(p),
};
cmd
@@ -41,6 +69,86 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
} else {
deps.writeFile(opts.output, res.data);
console.log(`Exported to ${opts.output}`);
for (const line of String(res.data).split("\n")) if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
// Check for referenced culture_file and startup files resolving from the output directory
const checkExists = deps.exists ?? ((p) => fs.existsSync(p));
const outDir = nodePath.dirname(nodePath.resolve(opts.output));
const rawRoot = res.headers?.get("X-Rig-Root");
let recordedRoot: string | undefined;
if (rawRoot) {
try {
recordedRoot = decodeURIComponent(rawRoot);
} catch {
recordedRoot = rawRoot;
}
}
try {
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"]);
} else if (typeof parsed["culture_file"] === "string") {
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);
}
}
}
}
for (const fileRel of filesToCheck) {
const targetPath = nodePath.resolve(outDir, fileRel);
if (!checkExists(targetPath)) {
const originNote = recordedRoot ? ` (came from ${recordedRoot})` : "";
console.warn(
`Warning: Referenced file '${fileRel}'${originNote} does not exist relative to '${outDir}'. ` +
`A rig launched from this export fails its startup until the file is copied next to it (or the export is imported with --rig-root ${recordedRoot ?? "<folder>"}).`
);
}
}
}
} catch {
// If YAML parsing fails, do not block export
}
}
});
+17 -2
View File
@@ -14,6 +14,7 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { renderCaughtTransportError } from "../cli-error.js";
import { resolveRigHandle } from "../rig-handle.js";
import type { StatusDeps } from "./status.js";
interface ForkResponse {
@@ -28,13 +29,16 @@ interface ForkResponse {
warnings?: string[];
};
image?: { id: string; name: string; version: string; pinned: boolean };
/** OPR.0.7.0.9: whether the source seat's launch additions were carried into the fork, and why not if they weren't. */
launchAdditions?: "carried" | "partial" | "not_carried";
launchAdditionsNote?: string;
}
export function forkCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("fork")
.description("Fork a live seat's context into a new seat (composes the shipped agent-image fork path)")
.argument("<source-session>", "Source session canonical name (e.g., dev-impl@openrig-delivery)")
.requiredOption("--rig <rig-id>", "Target rig id")
.requiredOption("--rig <rig>", "Target rig name or id")
.requiredOption("--pod <pod-namespace>", "Target existing pod namespace")
.requiredOption("--member <member-id>", "New member id for the forked successor")
.option("--keep-image", "Also capture a durable, pinned agent image (default forks one-shot with no library growth)")
@@ -65,10 +69,18 @@ Examples:
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return;
const client = deps.clientFactory(getDaemonUrl(status));
const resolution = await resolveRigHandle(client, opts.rig);
if (resolution.kind === "ambiguous") {
const msg = `'${opts.rig}' matches ${resolution.ids.length} rigs. Refusing to fork: an ambiguous name could act on the wrong rig. Re-run with the specific id: ${resolution.ids.join(", ")}`;
if (opts.json) console.log(JSON.stringify({ ok: false, code: "rig_ambiguous", message: msg, candidates: resolution.ids }, null, 2));
else console.error(msg);
process.exitCode = 1;
return;
}
const body: Record<string, unknown> = {
sourceSession,
rigId: opts.rig,
rigId: resolution.kind === "resolved" ? resolution.id : opts.rig,
pod: opts.pod,
member: opts.member,
};
@@ -104,6 +116,9 @@ Examples:
console.log(`Forked ${sourceSession} into rig ${opts.rig}`);
console.log(` Pod: ${data.result?.podNamespace ?? opts.pod}`);
console.log(` New seat: [${icon}] ${node?.logicalId ?? opts.member}${session}${error}`);
if (data.launchAdditions === "carried") console.log(" Launch additions: carried from the source seat");
if (data.launchAdditions === "partial") console.log(" Launch additions: partly carried from the source seat");
if (data.launchAdditionsNote) console.log(` Warning: ${data.launchAdditionsNote}`);
if (data.image) {
console.log(` Kept image: ${data.image.name} v${data.image.version} (pinned, protected from prune)`);
} else {
+220 -28
View File
@@ -1,3 +1,4 @@
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
@@ -182,32 +183,30 @@ 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")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
.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 }) => {
// 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 };
};
// 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;
// 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; }
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
@@ -236,7 +235,19 @@ 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" };
},
});
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
// 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; }
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
})
.addHelpText("after", `
@@ -259,7 +270,7 @@ Example:
human
.command("list")
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
@@ -270,7 +281,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, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); 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]";
@@ -278,7 +289,6 @@ 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");
@@ -520,15 +530,25 @@ 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 } = await import("@openrig/daemon/gateway-human-registry");
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
const res = showHuman(entityId);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
// 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) };
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})`);
r.connectorBindings.forEach((b, i) => {
console.log(` binding.${i}: ${b.kind}:${b.connectorRef} role=${b.role}${b.handle ? ` handle=${b.handle}` : " [outbound-only]"}`);
});
@@ -538,11 +558,26 @@ 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), binding.<n>")
.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)")
.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; }
@@ -599,7 +634,141 @@ Examples:
];
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
// 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 }));
});
return cmd;
@@ -622,3 +791,26 @@ 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;
}
+2 -2
View File
@@ -124,7 +124,7 @@ export function growCommand(depsOverride?: StatusDeps): Command {
let agentRef: string;
try {
agentRef = await resolveDefaultAgentRef(client);
agentRef = await resolveDefaultAgentRef(client, opts.runtime);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
@@ -135,7 +135,7 @@ export function growCommand(depsOverride?: StatusDeps): Command {
id: member,
agent_ref: agentRef,
runtime: opts.runtime,
profile: "default",
profile: opts.runtime === "terminal" ? "none" : "default",
cwd,
}));
let nodes: GrowNode[] = [];
+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>").requiredOption("--file <path>", "Disposition JSON with verdict, causalStart, steering, uncertainty, evidenceRefs").option("--json")
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")
.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, {}));
+86 -16
View File
@@ -23,6 +23,7 @@ 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.
@@ -71,6 +72,46 @@ 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 {
@@ -114,6 +155,8 @@ 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,
};
}
@@ -163,6 +206,8 @@ 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` });
@@ -255,20 +300,28 @@ 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);
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`,
});
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));
}
}
} catch {
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
@@ -276,6 +329,8 @@ 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({
@@ -481,6 +536,21 @@ 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)");
@@ -516,7 +586,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
console.log(`Verify it: rig host doctor ${res.entry.id}`);
printHostNextSteps(res.entry);
});
cmd
@@ -786,7 +856,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
return;
}
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
console.log(`Verify it: rig host doctor ${id}`);
printHostNextSteps(added.entry);
});
cmd
+2 -2
View File
@@ -34,7 +34,7 @@ type LaunchResponse = {
// OPR.0.4.3.20 FR-7 — a launched-entry status that means NO session is running (the
// operator must act). These are NOT successful launches — never print "Launched" for
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed"]);
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
function launchStatusRunning(status: string): boolean {
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
}
@@ -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 subset selection and non-target effects without mutation")
.option("--plan", "Show restore selection and non-target effects without mutation; does not preview first start of a never-started materialized seat")
.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 }) => {
+149 -9
View File
@@ -1,8 +1,10 @@
import { Command } from "commander";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
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 { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -17,19 +19,50 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
cmd
.command("serve")
.description("Start MCP server (stdio transport)")
.description("Start MCP server (stdio transport, or Streamable HTTP with --http)")
.option("--port <port>", "Daemon port override")
.action(async (opts: { port?: string }) => {
.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 }) => {
const deps = getDepsF();
let daemonUrl: string;
if (opts.port) {
const daemonPort = parseInt(opts.port, 10);
if (isNaN(daemonPort)) {
if (Number.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);
@@ -37,27 +70,134 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
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);
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 server.close();
await served.close();
}
});
const token = cmd.command("token").description("Grants for rig mcp serve --http: who may connect, as which seat, reaching what");
token
.command("add <name>")
.description("Add a grant and print its token once; only the token's hash is stored")
.requiredOption("--seat <session>", "The seat this caller acts as (its rows' source)")
.option("--rig <name...>", "Rigs whose seats and work this caller may reach")
.option("--root <name...>", "Shared file roots (OPENRIG_FILES_ALLOWLIST names) whose artifacts it may read")
.option("--workspace", "Also read the work tree and context (rig_slices, rig_mission, rig_context_*)")
.option("--owner", "An owner grant: every tool and object, like the local owner")
.action(async (name: string, opts: { seat: string; rig?: string[]; root?: string[]; workspace?: boolean; owner?: boolean }) => {
const file = mcpTokenFilePath();
try {
const issued = await addGrant(file, { name, seat: opts.seat, rigs: opts.rig ?? [], roots: opts.root ?? [], workspace: opts.workspace === true, owner: opts.owner === true });
console.log(`Token for ${name}, shown once (only its hash is kept in ${file}):`);
console.log(issued);
console.log(`The client sends it as: Authorization: Bearer <token>. Revoke it with: rig mcp token revoke ${name}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
token
.command("list")
.description("List grants (never their tokens or hashes)")
.option("--json", "JSON output")
.action((opts: { json?: boolean }) => {
const file = mcpTokenFilePath();
let grants;
try { grants = readGrants(file); } catch (err) { console.error((err as Error).message); process.exitCode = 1; return; }
const shown = (grants ?? []).map(({ tokenSha256: _hash, ...grant }) => grant);
if (opts.json) { console.log(JSON.stringify({ configured: grants !== null, file, grants: shown }, null, 2)); return; }
if (grants === null) { console.log(`No grants: rig mcp serve --http serves loopback only, unauthenticated (${file} doesn't exist).`); return; }
if (shown.length === 0) console.log(`No grants left in ${file}: every HTTP request is refused until one is added.`);
for (const grant of shown) {
console.log(`${grant.name}: seat ${grant.seat}${grant.owner ? ", owner" : ""}; rigs ${grant.rigs.join(", ") || "(none)"}; roots ${grant.roots.join(", ") || "(none)"}${grant.workspace ? "; workspace" : ""}`);
}
});
token
.command("revoke <name>")
.description("Remove a grant: its next request is refused. Work it already handed over stays with its owners")
.action(async (name: string) => {
try {
if (!(await revokeGrant(mcpTokenFilePath(), name))) { console.error(`No grant named ${name}.`); process.exitCode = 1; return; }
console.log(`Revoked ${name}: its next request is refused. Its rows and records stay.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
oauth?: { resource: string; issuer: 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}`);
process.exitCode = 1;
return;
}
const host = match[1]!.replace(/^\[|\]$/g, "");
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 } : {}) });
} 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
? `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);
});
await served.close();
}
+20 -7
View File
@@ -13,6 +13,11 @@ type SourceSnapshot = {
sources: { project: unknown; scopes: {id: string; source: string; hash: string}[];
roster: {session: string; nodeId: string; generation: string}[];
recent: {streamItemId: string; body: string; evidenceRef: string}[] };
/** Routing candidates (durable seats and their open work), versioned apart from `version`. */
routing?: { owners: {session: string; logicalId: string; role: string | null}[]; ownersTruncated: boolean;
work: {qitemId: string; destination: string; state: string; summary: string}[]; workTruncated: boolean } | null;
routingVersion?: string | null;
routingUnavailable?: string | null;
};
const hash = (text: string) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
function localText(file: string): string {
@@ -31,7 +36,7 @@ function response<T>(r: {status: number; data: T}): T {
throw Error(`${code}: ${message}`);
}
export async function prepareWorker(client: Client, opts: WakeOptions, read = localText) {
export async function prepareWorker(client: Client, opts: WakeOptions, read = localText, eligibleQuery: Record<string, string> = {}) {
const limit = Number(opts.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw Error("limit must be an integer in 1..100");
const text = read(opts.taxonomy), taxonomy = parse(text);
@@ -51,7 +56,7 @@ export async function prepareWorker(client: Client, opts: WakeOptions, read = lo
version: hash(JSON.stringify({sourceVersion: snapshot.version, taxonomyHash: hash(text), values})), values,
duplicateCandidates: snapshot.sources.recent.map(x => ({streamItemId: x.streamItemId, evidenceRef: x.evidenceRef})), relatedRefs: [],
};
const query = new URLSearchParams({classifierVersion: opts.classifierVersion, taxonomyVersion: taxonomy.version, evidenceEpoch: opts.evidenceEpoch, limit: opts.limit ?? "20", expectedOccupant: occupant.generation});
const query = new URLSearchParams({classifierVersion: opts.classifierVersion, taxonomyVersion: taxonomy.version, evidenceEpoch: opts.evidenceEpoch, limit: opts.limit ?? "20", expectedOccupant: occupant.generation, ...eligibleQuery});
const eligible = response(await client.get<{items: {streamItemId: string}[]; nextAfterSortKey: string | null}>(`/api/projects/eligible?${query}`));
return {snapshot, candidates, taxonomy: {version: taxonomy.version, source: opts.taxonomy, hash: hash(text), questions: taxonomy.fields}, eligible};
}
@@ -60,9 +65,16 @@ export async function prepareWorker(client: Client, opts: WakeOptions, read = lo
export async function runProjectWake(client: Client, opts: WakeOptions, read = localText, experiment?: {
decide: (prepared: Awaited<ReturnType<typeof prepareWorker>>) => WorkerOptions["classify"];
signal: AbortSignal; shouldStop: () => boolean; timeoutMs: number;
/** A decisions consumer's own candidate values, and fields it adds to each classification or abstention write. */
candidates?: (prepared: Awaited<ReturnType<typeof prepareWorker>>) => ClassificationCandidates;
classifyBody?: (input: Record<string, unknown>) => Record<string, unknown>;
abstainBody?: (input: Record<string, unknown>) => Record<string, unknown>;
/** Extra eligibility filters the consumer asks the daemon for (an older daemon ignores them). */
eligibleQuery?: Record<string, string>;
}) {
const prepared = await prepareWorker(client, opts, read);
const {snapshot, candidates, taxonomy} = prepared;
const prepared = await prepareWorker(client, opts, read, experiment?.eligibleQuery);
const {snapshot, taxonomy} = prepared;
const candidates = experiment?.candidates ? experiment.candidates(prepared) : prepared.candidates;
const {session, generation} = snapshot.occupant;
const suffix = (url: string) => `${url}${url.includes("?") ? "&" : "?"}expectedOccupant=${encodeURIComponent(generation)}`;
const get = async <T>(url: string): Promise<T> => response(await client.get<T>(suffix(url)));
@@ -84,12 +96,13 @@ export async function runProjectWake(client: Client, opts: WakeOptions, read = l
heartbeat: (id, actor) => post("/api/projects/lease/heartbeat", {leaseId: id, classifierSession: actor}),
},
attempts: {
eligible: input => get(`/api/projects/eligible?${new URLSearchParams(Object.entries(input).filter(([,v]) => v !== undefined).map(([k,v]): [string, string] => [k,String(v)]))}`),
eligible: input => get(`/api/projects/eligible?${new URLSearchParams([...Object.entries(input).filter(([,v]) => v !== undefined).map(([k,v]): [string, string] => [k,String(v)]),
...Object.entries(experiment?.eligibleQuery ?? {})])}`),
begin: input => post("/api/projects/attempts/begin", input),
abstain: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/abstain`, input),
abstain: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/abstain`, experiment?.abstainBody ? experiment.abstainBody({...input}) : input),
fail: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/fail`, input),
},
classifier: {classify: input => post("/api/projects/project", input)},
classifier: {classify: input => post("/api/projects/project", experiment?.classifyBody ? experiment.classifyBody({...input}) : input)},
stream: {getById: id => get(`/api/stream/${encodeURIComponent(id)}`)},
classify: experiment ? experiment.decide(prepared) : async request => {
if (snapshot.unavailable.length) return {kind: "abstain", reason: `candidate sources unavailable: ${snapshot.unavailable.join("; ")}`};
+52 -3
View File
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { experimentStatus, setExperiment, readBounded, JevRun, streamDecision, classifyCapture } from "./project-jev.js";
import { DecisionRun, decisionsStatus, readDecisionsSettings, type DecisionsSettings } from "../decisions/decisions.js";
import { recoverEffects, runDecideWake } from "../decisions/stream-consumer.js";
import fs from "node:fs";
/**
@@ -21,6 +23,7 @@ export interface ProjectDeps extends StatusDeps {
workerRead?: (file: string) => string;
experimentEnv?: NodeJS.ProcessEnv;
experimentFetch?: typeof fetch;
decisionsSettings?: () => DecisionsSettings;
}
async function withClient<T>(
@@ -52,8 +55,13 @@ 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).requiredOption("--config <file>", "Explicit local experiment JSON; contains no credential")
experimental.command(verb).description(experimentVerbDescriptions[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")
@@ -96,6 +104,16 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
}
});
cmd.command("decisions").description("Typed decisions (OpenRouter + Jev) for enabled consumers; off by default")
.command("status").description("What a decision run in this process would do; makes no provider call")
.option("--json", "JSON output")
.action(() => {
try {
const deps = getDeps();
printResult(true, decisionsStatus((deps.decisionsSettings ?? readDecisionsSettings)(), deps.experimentEnv, deps.workerRead), 200);
} catch (error) { printResult(true, {error: "decisions_status", message: error instanceof Error ? error.message : "unavailable"}, 400); }
});
for (const verb of ["candidates", "wake"] as const) {
cmd.command(verb)
.description(verb === "wake" ? "Run one bounded occupant wake with source-bound decisions or explicit Jev experiment; no registration" : "Read current source-bound candidates and eligible item IDs for the occupant")
@@ -105,9 +123,37 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
.requiredOption("--evidence-epoch <epoch>", "Owner-authorized evidence epoch; candidate changes do not retry terminal attempts")
.option("--decisions <file>", "JSON decisions bound to candidateSetVersion and each item's bodyHash")
.option("--experiment <file>", "Opt-in experimental Jev config; replaces --decisions for one bounded wake")
.option("--decide", "Typed decisions for eligible stream items (decisions.enabled with consumer stream): accepted answers become owned work or a recorded disposition; everything else stays with you")
.option("--limit <count>", "Items per bounded wake (1..100)", "20")
.option("--json", "JSON output")
.action(async (opts: WakeOptions & {experiment?: string}) => {
.action(async (opts: WakeOptions & {experiment?: string; decide?: boolean}) => {
if (opts.decide) {
if (verb !== "wake" || opts.decisions || opts.experiment) {
printResult(true, {error: "decide_usage", message: "--decide is for wake only and can't be combined with --decisions or --experiment"}, 400); return;
}
const deps = getDeps(), limit = Number(opts.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) { printResult(true, {error: "decide_usage", message: "limit must be an integer in 1..100"}, 400); return; }
const decisions = new DecisionRun({ consumer: "stream", settings: deps.decisionsSettings, env: deps.experimentEnv, send: deps.experimentFetch,
budget: { maxCalls: limit, deadlineMs: Math.min(600_000, limit * 10_000) } });
if (decisions.unavailable) {
// Nothing is asked, but already-accepted effects are still applied: a disable never strands committed work.
let recovered: unknown = [{ outcome: "skipped", reason: "daemon unavailable; effects wait for a wake" }];
try { await withClient(deps, async client => { recovered = await recoverEffects(client, opts.project); }); }
catch (error) { recovered = [{ outcome: "skipped", reason: `effects not checked: ${error instanceof Error ? error.message : "daemon unavailable"}; they wait for a wake` }]; }
printResult(true, {decisions: decisions.unavailable, status: decisionsStatus((deps.decisionsSettings ?? readDecisionsSettings)(), deps.experimentEnv, deps.workerRead), recovered}, 200); return;
}
process.once("SIGINT", decisions.stop); process.once("SIGTERM", decisions.stop);
try {
await withClient(deps, async client => {
try {
const result = await runDecideWake(client, opts, decisions, deps.workerRead);
console.log(JSON.stringify(result));
if (["unavailable", "lease_lost"].includes(result.result.state)) process.exitCode = 1;
} catch (error) { printResult(true, {error: "worker_unavailable", message: error instanceof Error ? error.message : "unavailable", phase: "prepare", nextWakeAt: new Date(Date.now() + 60_000).toISOString()}, 409); }
});
} finally { process.removeListener("SIGINT", decisions.stop); process.removeListener("SIGTERM", decisions.stop); }
return;
}
let run: JevRun | undefined;
try {
if (opts.experiment) {
@@ -120,7 +166,10 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
}
await withClient(getDeps(), async client => {
try {
const result = verb === "wake" ? await runProjectWake(client, opts, getDeps().workerRead, run ? {
// The experiment records its own attempt identity: rig stream show labels its classifications, and the automatic
// path doesn't treat its abstentions as intake's.
const wakeOpts = run ? { ...opts, classifierVersion: `${opts.classifierVersion}+experiment` } : opts;
const result = verb === "wake" ? await runProjectWake(client, wakeOpts, getDeps().workerRead, run ? {
decide: prepared => streamDecision(run!, prepared), signal: run.controller.signal,
shouldStop: run.stopped, timeoutMs: run.config.timeoutMs,
} : undefined) : await prepareWorker(client, opts, getDeps().workerRead);
+33 -3
View File
@@ -3,9 +3,39 @@ 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 { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
import { ScopeCliError } from "../lib/scope/types.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 { selectProofContractBody, isScaffoldPlaceholderText } from "../lib/scope/scaffold-placeholder.js";
import { parseLogicalCheckboxes } from "../lib/scope/logical-checkbox.js";
@@ -230,7 +260,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 = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const slice = findSliceInCatalog(missionsRoot, slicePath, opts.mission ?? null, parentOpts.workspace, warns);
// Resolve the artifact body.
let body = "";
+257 -24
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, DaemonTimeoutError } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -19,9 +20,44 @@ 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, usage signals, and interruption-safe account switching",
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
);
const getDeps = (): StatusDeps =>
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -138,12 +174,16 @@ 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) ?? [];
@@ -164,7 +204,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["accountId"] ? `account ${id["accountId"]}`
return id["source"] === "configured credential label" ? `credential ${id["credentialLabel"]} (operator label)` : 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";
@@ -216,6 +256,7 @@ 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) {
@@ -227,7 +268,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})`);
console.log(`${pad} ${windowName(w)} ${left} ${level} resets ${w["resetsAt"] ?? "unknown"} observed ${w["observedAt"]}${seat} (${fresh})${w["status"] ? ` · provider ${w["status"]}` : ""}`);
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"]}`);
}
@@ -240,7 +281,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"
console.log(r["provider"] === "claude" && (r["source"] as Record<string, unknown> | undefined)?.["kind"] !== "claude_token_probe"
? `${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`);
}
@@ -368,14 +409,22 @@ 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 }) => {
.action(async (opts: { json?: boolean; refresh?: boolean; provider?: string; allHosts?: boolean; hosts?: string; claudeTokenFile?: string; credentialLabel?: 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;
@@ -394,7 +443,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 }, { timeoutMs: 60_000 }),
answer: await client.post<Record<string, unknown>>("/api/provider/usage/refresh", { provider, ...(tokenSelected ? { claudeToken: { label: opts.credentialLabel, tokenFile: opts.claudeTokenFile } } : {}) }, { 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
@@ -457,46 +506,230 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("precheck")
.description("Whether switching a seat to an account is safe (never offers an unsafe switch)")
.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")
.requiredOption("--seat <s>", "The seat session")
.requiredOption("--to-account <a>", "The target account ref")
.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("--json", "Output as parseable JSON")
.action(async (opts: { seat: string; toAccount: string; json?: boolean }) => {
.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;
}
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: opts.toAccount });
const res = await client.get<{ safe: boolean; reasons?: string[] }>(`/api/provider/precheck?${qs.toString()}`);
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()}`);
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(", ")}`);
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);
}
if (!res.data.safe) process.exitCode = 1; // let scripts gate on an unsafe verdict
});
cmd
.command("switch")
.description("Switch a seat to an account (precheck-gated; the daemon orchestrates the 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")
.requiredOption("--seat <s>", "The seat session")
.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("--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")
.option("--json", "Output as parseable JSON")
.action(async (opts: { seat: string; toAccount: string; forceUnsafe?: boolean; json?: boolean }) => {
.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;
}
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const res = await client.post<{ outcome?: string; reasons?: string[] }>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
forceUnsafe: opts.forceUnsafe ?? false,
});
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.` } };
}
if (handleHttpError(res, "switch")) return;
if (opts.json) console.log(JSON.stringify(res.data));
else console.log(res.data.outcome ?? "unknown");
if (res.data.outcome === "failed_safely") process.exitCode = 1;
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;
});
// Slice 18 — subscription move reports: what the agent that moved a Codex home reports, kept as queue items tagged
// subscription-move. Reading them back answers "why did these seats move?"; the Fleet app reads the same route.
type MoveEntry = {
qitemId: string; reportedAt: string; reportedTo: string; followedBy: string[];
report: { host: string; home: string; from: string; to: string; reason: string; follows?: string; seats: Array<{ seat: string; result: string; missing?: string }>; peers?: Array<{ seat: string; note: string }> };
};
const moves = cmd
.command("moves")
.description("Recent subscription move reports on this daemon, newest first: from and to, why, and each seat's result")
.option("--seat <seat>", "Only reports that restarted or listed this seat, among the reports read")
.option("--limit <n>", "How many of the newest reports to read, before --seat filters them (default 50, at most 200)")
.option("--json", "Output as parseable JSON")
.action(async (opts: { seat?: string; limit?: string; json?: boolean }) => {
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const qs = new URLSearchParams();
if (opts.seat) qs.set("seat", opts.seat);
if (opts.limit) qs.set("limit", opts.limit);
const res = await client.get<{ moves: MoveEntry[]; unreadable: Array<{ qitemId: string; reason: string }>; truncated: boolean }>(
`/api/provider/moves${qs.toString() ? `?${qs.toString()}` : ""}`,
);
if (handleHttpError(res, "moves")) return;
if (opts.json) {
console.log(JSON.stringify(res.data));
return;
}
const { moves: entries, unreadable, truncated } = res.data;
if (entries.length === 0) console.log("No subscription move reports.");
for (const { qitemId, reportedAt, reportedTo, followedBy, report } of entries) {
console.log(`${reportedAt} ${qitemId} ${report.host} ${report.home}: ${report.from} → ${report.to} (to ${reportedTo})`);
console.log(` why: ${report.reason}`);
for (const seat of report.seats) console.log(` ${seat.seat}: ${seat.result}${seat.missing ? `; missing: ${seat.missing}` : ""}`);
for (const peer of report.peers ?? []) console.log(` ${peer.seat}: ${peer.note}`);
if (report.follows) console.log(` follows ${report.follows}`);
if (followedBy.length > 0) console.log(` followed by ${followedBy.join(", ")}`);
}
for (const item of unreadable) console.log(`unreadable: ${item.qitemId}: ${item.reason}`);
if (truncated) console.log(`(only the newest ${opts.limit ?? 50} reports were read; older ones may exist: raise --limit)`);
});
moves
.command("report")
.description("File a report of a move you made or attempted, as a queue item to whoever asked (it moves nothing)")
.requiredOption("--file <path>", "The report as JSON, or - for stdin")
.requiredOption("--to <seat>", "Who gets the report: whoever asked for the move, or the person")
.option("--follows <qitemId>", "The earlier report this one follows up (sets the report's follows)")
.option("--evidence-ref <ref>", "The evidence a report to a person's seat names, e.g. the saved --verify output")
.option("--json", "Output as parseable JSON")
.addHelpText("after", `
The report (version 1):
{ "version": 1, "host": "<machine>", "home": "<CODEX_HOME>", "from": "<subscription label>", "to": "<subscription label>",
"reason": "<why, in your words>",
"policy": { "ref": "account-policy", "version": "<manifest version>" } (or { "none": true })
"readings": [{ "subject": "<allowance>", "basis": "observed", "observedAt": "<when observed>", "summary": "<what it said>" },
{ "subject": "<allowance>", "basis": "expected", "expectedFullSince": "<the reset>", "summary": "..." }],
"seats": [{ "seat": "<seat>", "result": "moved",
"accountEvidence": { "state": "matched", "detail": "<Account line>", "at": "<time>" },
"toolTurn": { "state": "observed", "at": "<time>" } },
{ "seat": "<seat>", "result": "unconfirmed", "missing": "<what wasn't seen>" }],
"peers": [{ "seat": "<seat>", "note": "not restarted; account unverified" }] }
moved needs accountEvidence matched and toolTurn observed, as rig seat restart --verify reports them; until both are
seen, report the seat unconfirmed (or unknown) with what's missing. An expected reading (slice 01's expectation after
a reset) stays labelled expected. Labels and references only: a value in a recognized credential format is refused.
A refused report records nothing and changes nothing about the move: correct it and file it again; never repeat the
switch or restart because a report was refused.`)
.action(async (opts: { file: string; to: string; follows?: string; evidenceRef?: string; json?: boolean }, command: Command) => {
const json = opts.json || command.parent?.opts().json;
let report: unknown;
try {
report = JSON.parse(opts.file === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(opts.file, "utf8"));
} catch {
console.error(`Could not read a JSON report from ${opts.file}. Nothing was recorded, and the move itself is unchanged.`);
process.exitCode = 1;
return;
}
if (opts.follows && report && typeof report === "object" && !Array.isArray(report)) report = { ...report, follows: opts.follows };
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const res = await client.post<{ qitemId?: string; errors?: string[] }>("/api/provider/moves", {
to: opts.to, report, ...(opts.evidenceRef ? { evidenceRef: opts.evidenceRef } : {}),
});
if (res.status < 400 && json) console.log(JSON.stringify(res.data));
else if (res.status < 400) console.log(`Filed ${res.data.qitemId} to ${opts.to}`);
else {
for (const error of res.data.errors ?? []) console.error(`- ${error}`);
handleHttpError(res, "move report");
}
});
return cmd;
+58 -43
View File
@@ -11,6 +11,7 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer,
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { formatSeatActivity as formatActivity } from "../lib/seat-activity.js";
import type { AggregatedPayload, PerHostStatus } from "../lib/hosts/fanout-contract.js";
interface PsEntry {
@@ -43,6 +44,10 @@ interface PsEntry {
}
interface NodeEntry {
/** Served daemon attention, never re-derived here. Missing on an older daemon. */
needsAttention?: boolean;
arbitratedNeedsInput?: boolean | null;
needsInputEvidence?: { rung: string; observedAt: string } | null;
rigId: string;
rigName: string;
logicalId: string;
@@ -78,7 +83,9 @@ interface NodeEntry {
needsInput: { count: number; reason: string | null };
decidedBy: string | null;
} | null;
identityVerdict?: { reason?: string | null } | null;
identityVerdict?: { verdict?: string; reason?: string | null; observedAt?: string | null } | null;
/** The registry's recorded session status, kept when the served sessionStatus is corrected. */
storedSessionStatus?: string | null;
/** A community harness's seat: its label, maintainer and declared limits. */
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
@@ -226,8 +233,13 @@ const ALLOWED_NODE_FIELDS = new Set([
"inProgressWorkCount",
"blockedWorkCount",
"agentActivity",
"needsAttention",
"arbitratedNeedsInput",
"needsInputEvidence",
"contextUsage",
"heldReason",
"storedSessionStatus",
"identityVerdict",
]);
interface PsCliOptions {
@@ -609,13 +621,6 @@ function selectFanOutFields(entries: Array<Record<string, unknown>>, fields: str
});
}
function needsAttention(node: NodeEntry): boolean {
return node.lifecycleState === "attention_required"
|| node.startupStatus === "attention_required"
|| node.startupStatus === "failed"
|| node.agentActivity?.state === "needs_input";
}
function hasCurrentUsageLimit(node: NodeEntry): boolean {
const activity = node.agentActivity;
return activity?.state === "unknown" && activity.reason === "usage_limit"
@@ -636,6 +641,17 @@ function missingSessionGuidance(node: NodeEntry): string | null {
: null;
}
/** A recorded-running seat whose applicable identity check could not reach tmux: its liveness is
* unconfirmed, not known dead. Codex's mismatch verdict keeps its own attention path. */
function livenessUnconfirmed(node: NodeEntry): boolean {
return node.sessionStatus === "running" && !node.intentionallyStopped
&& node.identityVerdict?.verdict === "tmux_unavailable";
}
function livenessLine(seat: string, node: NodeEntry): string {
return `Liveness (${seat}): recorded running, not confirmed: tmux was unavailable at the last observation (${node.identityVerdict?.observedAt ?? "time unknown"}).`;
}
// OPR.0.4.0.34 — the compact orch field set (PRD FR-4). Carries the
// source-available identity + state + resume-summary fields an orchestrator
// needs at a glance, WITHOUT leaking the resume token value or resumeCommand
@@ -691,7 +707,7 @@ export function formatDeclaredModel(model: string | null | undefined): string {
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const attention = needsAttention(n);
const keepDiagnostics = n.needsAttention !== false;
const observation = n.modelObservation == null ? null : modelObservation(n.modelObservation, n.sessionStatus === "running");
const compact: Record<string, unknown> = {
// identity (rigId/rigName + logicalId + session) — disambiguates under -A.
@@ -715,6 +731,8 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
modelObservation: !observation ? null : observation.status === "observed"
? [observation.model, observation.source, observation.observedAt]
: { status: "unknown", reason: observation.reason },
// Provenance remains on --full / --fields; the compact read keeps the served flag.
...(n.needsAttention === undefined ? {} : { needsAttention: n.needsAttention }),
// lifecycle + session/startup state.
sessionStatus: n.sessionStatus,
startupStatus: n.startupStatus,
@@ -722,7 +740,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
oriented: n.oriented ?? "n-a",
lifecycleState: n.lifecycleState,
// Keep the current usage limit visible without classifying it as human attention.
agentActivity: attention || hasCurrentUsageLimit(n)
agentActivity: keepDiagnostics || hasCurrentUsageLimit(n)
? { state: n.agentActivity?.state ?? "unknown", reason: n.agentActivity?.reason }
: { state: n.agentActivity?.state ?? "unknown" },
// Work counts. Compact keeps the base pending-only key plus the active
@@ -740,9 +758,17 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
lastActivity: n.agentActivity?.sampledAt ?? n.startupCompletedAt ?? null,
};
if (n.intentionallyStopped !== undefined) compact.intentionallyStopped = n.intentionallyStopped;
// The served identity evidence (verdict, reason, observation time) and the registry's recorded
// status, without the raw evidence; recorded running is not a check that the process is alive.
if (n.storedSessionStatus !== undefined) compact.storedSessionStatus = n.storedSessionStatus;
if (n.identityVerdict) {
const { verdict, reason, observedAt } = n.identityVerdict;
// Only what was served: an absent time stays absent, never filled in.
compact.identityVerdict = { ...(verdict ? { verdict } : {}), ...(reason ? { reason } : {}), ...(observedAt ? { observedAt } : {}) };
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (attention && n.latestError) {
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
return compact;
@@ -899,7 +925,8 @@ podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarn
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, contextUsage, heldReason.
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1328,12 +1355,11 @@ async function handleNodes(
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
if (useCompact) {
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON"));
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const attn = needsAttention(n);
const reason = missingSessionGuidance(n) ?? (attn || hasCurrentUsageLimit(n)
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
: "—");
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
n.canonicalSessionName ?? "—",
@@ -1341,6 +1367,7 @@ async function handleNodes(
formatActivity(n),
formatHasWork(n.hasAssignedWork, n.assignedWorkCount ?? n.pendingWorkCount),
reason,
n.needsAttention === undefined ? "?" : n.needsAttention ? "yes" : "no",
));
}
} else {
@@ -1367,7 +1394,8 @@ async function handleNodes(
formatActivity(n),
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}` : "—"),
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
}
@@ -1381,6 +1409,9 @@ async function handleNodes(
for (const n of humanList as NodeEntry[]) {
if (n.communityHarness) console.log(formatCommunityHarnessLine(n.canonicalSessionName ?? n.logicalId, n.communityHarness));
}
for (const n of humanList as NodeEntry[]) {
if (livenessUnconfirmed(n)) console.log(livenessLine(n.canonicalSessionName ?? n.logicalId, n));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_NODE_BUDGET;
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
@@ -1391,27 +1422,6 @@ async function handleNodes(
}
}
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "communityHarness">): string {
// S19 — render from the served taxonomy state when present: needs-input as
// count(+reason), otherwise the bridge's display value. No local arbitration.
const tax = n.activityState;
if (tax) {
if (tax.needsInput.count > 0) return `needs-input x${tax.needsInput.count}`;
// A community harness has no needs-input source in that state, so its zero isn't a clearance: a prompt the
// screen shows is kept.
if (n.communityHarness && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
return tax.display;
}
// Legacy fallback (a daemon without the S19 enrichment) — retires with the
// pre-taxonomy surfaces per the ladder-as-migration rule.
const activity = n.agentActivity;
if (!activity) return "unknown";
if (activity.state === "running") return "running";
if (activity.state === "needs_input") return "needs_input";
if (activity.state === "idle") return "idle";
return "unknown";
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
@@ -1443,6 +1453,9 @@ function padRigRow(cells: string[], widths: number[]): string {
return cells.map((cell, i) => i < widths.length ? fitRigCell(cell, widths[i]!) : cell).join(RIG_COLUMN_SEPARATOR);
}
// A cell filled to its width, or cut to it, still ends before the next column begins.
const NODE_COLUMN_SEPARATOR = " ";
export function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, model: string, status: string, startup: string, oriented: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
return [
fitCell(rig, 30),
@@ -1459,22 +1472,24 @@ export function padNodeRow(rig: string, pod: string, member: string, session: st
// Slice 15 — distinct TERMINAL + WORK columns.
fitCell(terminal, 9),
fitCell(work, 6),
fitCell(activity, 12),
// Wide enough for a needs-input count, as in the compact table.
fitCell(activity, 17),
fitCell(ctx, 6),
fitCell(restore, 10),
error,
].join("");
].join(NODE_COLUMN_SEPARATOR);
}
export function padCompactNodeRow(rig: string, session: string, lifecycle: string, activity: string, work: string, reason: string): string {
export function padCompactNodeRow(rig: string, session: string, lifecycle: string, activity: string, work: string, reason: string, attention = "?"): string {
return [
fitCell(rig, 22),
fitCell(session, 38),
fitCell(lifecycle, 11),
fitCell(activity, 14),
fitCell(activity, 17),
fitCell(work, 6),
fitCell(attention, 4),
reason,
].join("");
].join(NODE_COLUMN_SEPARATOR);
}
// Slice 15 — render the terminal-active primitive honestly.
Loaded 100 of 1204 files, more files were not shown because too many files have changed in this diff. Show more