The invented-request problem is addressed for 0.7.0, so it leaves the
known gaps. A Slack channel-mapping change needs a daemon restart; the
page no longer claims disable then enable does or doesn't reload it.
Enabling marks any alerts waiting to post as already seen
(seedBacklogAsHistory, queue-access.ts:106-109, called at
gateway.ts:292), so open exchanges miss them.
A later edit to the cached hook scripts keeps the trust OpenRig granted
on first install, as with any hook trusted in Codex. The what's-new page
and the refocus channel reference now say so.
The what's-new page an agent reads after upgrading still described
0.6.12. It now lists what behaves differently from 0.6.14 and what to do
about it: the migrations and settings, the clean-start environment, the
plugin refresh and its copies, quiet hours and modes, rig names in
place of ids, restore and launch changes, the openrig-core plugin's
return to Codex seats with first-install hook trust, changed output, and
the known gaps.
The managed-refresh log line now says the file's previous contents were
preserved before the refresh, not that custom behaviour stopped: it runs
before any write, and a later copy failure can still skip the refresh.
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).
(cherry picked from commit 5f64645e29)
A mode applied with `--until` recorded its end time but nothing ended it, and there was no account of what happened while a person's mode was on.
Now, when a mode ends, by "I'm back" or at the end time they chose, the operator can give the person an account built from the records: the mode and its settings, which of their own newer choices still stand, what's pending or failed, the mode they're in now, each decision made for them and which can't be undone, and what's waiting for them. What no record supports, such as work done or failures elsewhere, is listed as not tracked. A late click on an ask that was already decided says who decided it and what. The operator explains a mode before its first use, and agents that would ask the person read the decision room first.
The account is read-only (`rig gateway human mode account <person> <applicationId>`) and reuses the existing mode, decision and hold reads. It shows only what was that person's: an ask that has since moved to someone else is listed as stale without its current summary or state, and an ask that was delivered to them and handled afterwards isn't listed. The scheduled end is a `mode-scheduled-end` policy on the existing watchdog engine, armed with the application and ending only that application. It creates at most one notice row (`qitem-mode-end-<applicationId>`): to the kernel operator, otherwise the seat that applied the mode if the queue accepts it, otherwise the person's own address as an update. If the person registry can't be read, the notice waits and is sent once it can; if no one can receive it, the job ends and records why. The queue's destination check moved from `startup.ts` into its own module, unchanged, so the timer can use it. The late-click reply goes through the same Slack escaping and redaction as the other replies. `mode apply --until` without `--tz` uses this machine's zone and says so. The guidance is in the kernel operator's `role.md` and `messaging-the-human`, mirrored, with the skill digests regenerated.
Limits: a mode applied with `--until` on main before this change has no timer and still ends only by hand (no published release had `mode apply --until`). The scheduled end runs on the 60-second cadence, and after downtime it ends on the first pass with both times in the account. Without quiet hours, an ask can still be posted between being created and decided; a late click then names the decision. Nothing that works today is refused; the account route's three errors (`application_not_found`, `still_on`, `modes_unavailable`) are its own. Native, real-Slack and operator journeys weren't run.
+948/-44 in 23 files: source +452/-38 (+37/-25 of it the destination check's move), tests +376/-3, guidance, docs and digests +120/-3.
With Slack forwarding between machines (`forwardChannels`), removing a forward entry could strand the relays a hub had retained, and status didn't show where a channel with no mapping goes.
Now retained relays stay visible and keep retrying the host they came from after their forward entry is removed. Status names the configured local routing fallback and its destination. A disabled connector's status counts all three inbound stores and says retry is paused; an enabled machine that only receives relayed events still reads as awaiting retry. A repeated `rig slack enable` stays a no-op and now says configuration and tokens weren't reloaded, pointing to a daemon restart, which keeps unposted alerts to a person (disabling and re-enabling would mark them seen).
`docs/reference/slack-app-setup.md` explains the pairing credential and says to let relays finish before editing forwarding entries. Zero retained records doesn't prove nothing is in flight. No quiesce step or channel-ownership mechanism is added.
Refs #1711. Tests use real wire, router and store code with synthetic HTTP and sockets, not live Slack or two real machines.
+102/-48 in 8 files: source +31/-38, tests +59/-8, docs +12/-2. Nothing that works today is refused.
`rig gateway human quiet` still said delivery didn't act on quiet hours, which has been wrong since #1606. It now says: 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.
`docs/reference/slack-app-setup.md` gains "Quiet hours for a person (optional)", describing current behaviour only: what is held and what isn't (escalations, the operator rung, and the below-posting-level log-only case), that posts already confirmed or partly in Slack finish as themselves, and how held asks are released.
Text only, no behaviour change: one help line and about 20 lines of docs.
#1717 sends repeated watchdog prompt refusals up the wake ladder, and a run of refusals restarts after the ladder sees the seat's prompt clear. It recognised that clear by the alert's resolution text equalling `prompt cleared`. But a closing resolution is appended to any existing text, and `routeToFallback` writes `fallback: <reason>` there while the alert stays open, so after a reroute a real clear wasn't recognised and the next blocked episode at that seat stayed quiet.
Now, when the ladder retires a prompt alert because the prompt read clear, it first appends its own transition (actor `wake-ladder@system`, note `prompt escalation: the seat's prompt read clear`), and the run boundary requires that transition. The transition log is append-only, so a fallback reason can neither produce nor hide it. An alert retired because its episode ended while the prompt stayed blocked writes none, and `findWakeRecovery` and the retirement note are unchanged. The ladder's backoff ignores the new note.
A new case routes an open alert to a fallback, clears, re-blocks and expects one new alert after two refusals; the two-job case stays as the negative control. +23/-7 in 2 files: source +9/-7, tests +14/-0.
When a seat sits at a permission or selection prompt, its watchdog reminders are refused, and since #1713 the history records them as `refused`, but nobody was told. In one real case a two-hour reminder was refused six times in ten hours.
Now a reminder refused twice in a row at the same seat's prompt joins that seat's existing prompt alert (`advancePromptRefusals` in `queue-wake-ladder.ts`). It reaches the seat's orchestrator, the source of a `delegates_to` edge into it, or else the operator rung, which reaches a person. One alert per seat lists the job ids beside any queue rows the same prompt blocks, and a reminder that joins an existing alert is named in a transition note without anything being sent again. The blocked seat is never typed into or retried, and the parked-owner reminder is excluded because its refusals already arrive on rows.
The alert stands down when the prompt clears or the job's next attempt isn't a refusal at that seat. Its episode key names where the run starts: the job's latest other entry at that seat, or the seat's latest alert the ladder retired because the prompt read clear, whichever is later. So a run keeps one key however long it gets, a hand-closed alert stays closed, and a prompt that clears and comes back gets a new alert after two new refusals.
Limits: an alert that was routed to a fallback destination before its prompt cleared doesn't mark that clear, so the next blocked episode at that seat stays quiet until a reminder lands (a follow-up fixes it). After about 14 days of uninterrupted refusals, retention can remove the entry the key follows, and the alert repeats once.
+175/-12 in 2 files: source +74/-11 in `queue-wake-ladder.ts`, tests +101/-1.
`rig watchdog status` showed every watchdog delivery that wasn't held back as `sent`, including a reminder refused because the seat sat at a permission or selection prompt, and one that never reached tmux. A seat waiting at a first-use prompt refused its reminder six times in ten hours, and every attempt read `sent`.
Now a prompt refusal reads `refused`, any other failed delivery reads `failed`, and only a delivered reminder reads `sent`. The reason stays in `evaluationNotes.deliveryReason`.
The one writer of an attempt's outcome (`watchdog-policy-engine.ts`) maps it from the delivery status, using the parked-owner policy's existing refusal rule, now exported. The parked-owner policy skips only `skipped` and `terminal` entries, so it still sees refusals and failures, and its results are unchanged. The send path, its prompt protection, the schedule after an attempt and park wake receipts (which still need a delivered send) are unchanged.
Tests: four cases that pinned a failed or refused attempt as `sent` now expect `failed` or `refused`, and two fixtures write history the way the engine does. +20/-13 in 7 files (source +10/-5).
On Windows, Node's `os.homedir()` follows `USERPROFILE`, not `HOME`, so the hermetic test setup's check that provider homes stay inside the fixture failed before any test ran, for anyone running the focused tests there.
The setup now sets `USERPROFILE` to the fixture user home on Windows, beside `HOME`, through a small function in the same file. The containment check is unchanged, and macOS and Linux get exactly the assignments they did before. Three cases check the environment the setup selects for each platform.
Test infrastructure only, +13/-1 in 2 files. Not run on real Windows: required CI runs on macOS and Linux.
The queue accepted `state=done` with `closureReason=blocked_on` and silently dropped the work it was meant to wait on.
That request is now rejected with HTTP 400 `invalid_closure_reason` before anything changes: no update, transition, nudge or completion effect. The message says `done` is final and shows the command to use instead, `rig queue block <id> --on <child>` (or `rig queue update <id> --state blocked --blocked-on <child>`). The same correction is returned for a row that's already done. Ordinary completions, the other closure reasons, handoffs and `blocked` records with `blocked_on` are unchanged, and existing rows aren't rewritten. The `queue-handoff` skill and the coordination doc now teach the block command for waiting on another item.
A Claude seat installed with Claude's native installer on macOS, whose process is named by its version (for example `2.1.300`), is no longer marked as needing attention after a restore that resumed it, and `rig reconcile-session` no longer reports that its pane command doesn't confirm `claude-code`.
A version label stands for `claude` only for an unmanaged seat, and only with an OS executable witness: the pane's one foreground Claude is that version-named process in two samples, the OS reports its executable inside the installer's `versions` folder with a basename equal to its process name, and its command line names `claude` as the program. Managed seats, the managed launch's exact proof, shell labels and `claude` labels are unchanged. A path read that times out ends the witness for the rest of that wait.
Not yet run on a real Mac with the native installer; the tests stub the pane labels and process rows.
Refs #86
A failed Slack ask should leave one recovery item for the asking agent. New gateway notices for unrenderable content or Slack `invalid_blocks` now carry the existing `recovery-for:` tag, so the stuck sweep reuses them instead of creating another item and wake. Workflow overdue exceptions keep their separate exception-owner route. A missing notice still permits sweep recovery; existing notices are not backfilled or closed.
Cost: source +7/-4, tests +28. No new registry, query, setting or refusal.
`rig ps --host <id> --rig <name>` printed `[via host=…]` and an empty list, exiting 0, when that host had no rig of that name, unlike the local `rig ps --rig`.
Now it prints `Rig 'x' not found on host h. List rigs with: rig ps --host h` to stderr and exits 1; a name only an archived rig has points at `--include-archived`, with one extra read on that path only. `--json` keeps its empty result, and the check runs before other filters and `--summary`, so a real rig that a filter empties still lists. A failed rig lookup is reported as the fetch failure it is, not as "not found" (in the node view too). The help lists the exit code.
When a person has told an agent outright that it may answer a kind of permission prompt for them, the kernel operator's guidance (`role.md` step 10) and the rigs skill's "Check it's ready" (both copies) now say the agent may answer the future prompts that instruction covers.
What stays: a yes to one prompt still isn't a yes to the next, the "don't ask again" rule, and step 10's existing permission choices. Nothing changes in code: no automatic yes, no polling, no prompt override and no native setting change.
Text only, +22/-3 in 3 files.
`rig snapshot list` padded its columns to fixed widths (ID 27, Kind 8, Status 10). The `auto-pre-down` snapshot every `rig down` takes is 13 characters, so its Status and Created ran out from under their headings.
Each column now fits its widest value or heading, two spaces apart, and the last column is unpadded. The ids, kinds, statuses and times printed are unchanged; only the spacing differs.
`rig bundle check` reported a directory named like a declared file (for example `README.md`) or a startup file as a readable document, so an invalid bundle passed the file check.
Now each declared path is checked for its kind: a declared file must be a regular file, a local agent ref must be a directory, and skill and resource paths keep their supported file-or-directory shape. A path of the wrong kind gets its own finding that names the kind found and the kind expected, and the check exits 1; a missing, unreadable or outside-folder path keeps its existing message. `openrig-home:` plugin sources are still left to the installing host, as before.
When a person's active mode (asleep, away, out of office) has a decision room, the agent that would otherwise have asked them can now decide inside it with `rig gateway human mode decide`. The decision is recorded on the ask itself as made on the person's behalf, never as their answer: the room as it stood, the deciding agent, the choice and reason, and whether it can be undone. The ask closes, and if it hasn't been posted to them yet, it isn't. `rig gateway human mode room <person>` shows the current room, and `decisions` lists what was decided under one mode for when they're back.
A decision is refused, writing nothing, when there's no active mode or room, when the mode changed since the agent read it, for a permission prompt or an approval gate (by the gate tag or the tier), for anyone but the agent that asked, for a choice the ask didn't offer, and once the person has started answering. Answers a person gives are recorded exactly as before.
Limit: an ask is held back only while quiet hours are on, so with a room and no quiet hours it can reach Slack before the agent decides, and nothing is added to that thread yet.
Cost: source +275/-14, tests +256/-6, docs +3; one migration (a nullable column on queue items); the refusals are all on the new path.
Slice 04-B2 (0.7.0).
A parked agent holding work could stop getting its reminder indefinitely: an earlier reserved or delivered attempt, or a failed write of its note, blocked every later one.
Now a reminder whose last attempt failed, ended unknown or was never recorded is tried again on the existing watchdog cadence while the seat stays parked, and a note or telemetry failure can't stop a current reminder. Two existing rules stay: a reminder recorded as delivered to the same known occupant isn't repeated until the work changes, and a seat refused at an interactive prompt keeps its existing recovery path instead of being re-sent every pass. Owner rotation counts attempts of every outcome, and an owner past the history window still gets its turn.
The managed-compaction check that already ran when the reminder was evaluated now also runs at the actual paste and Enter, for this reminder only, in ordinary and draft-aware delivery: a compaction that starts after the paste stops the Enter and keeps the honest partial result, instead of sending the reminder into the generic wake ladder. An ordinary /compact keeps its existing behaviour.
Cost: source net -39, tests net +95, docs +7. No new refusal, error code, schema or scheduler.
Slice 22 lane A (0.7.0).
rig up --env KEY=VALUE adds a variable for one launch, and OpenRig then takes it back out of the tmux session's environment. When that cleanup failed, the launch was treated as failed and the seat that had started fine was torn down.
Now the seat keeps running. The launch warns, naming the variable (never its value) and the tmux command that would remove it, and until then a later terminal in that session would get it. The warning reaches the response and the CLI output on every path, including a launch where every seat stops at an attention gate. The rig-spec reference says so. The old cleanup-error plumbing is removed, so the change is net smaller.
Slice 22 lane D (0.7.0).
rig status, the TUI's rig status refresh, the restore preview before rig up or rig restore, and the restore check all wait on readFreshOccupantRelations. It found a rig's seat.fresh_launched events with WHERE rig_id = ? AND type = ?, which only idx_events_rig_seq could serve, so SQLite read every event the rig had ever recorded, payload included. On a copy of a real install's database, the largest rig had 108,217 events (46 MB): 4-6 s per call cold, 33-51 ms warm. The status route makes this read two or three times per request.
The query now goes from the rig's nodes into each node's seat.fresh_launched events through idx_events_node_type_seq, keeping e.rig_id = ?. It takes 0-3 ms either way and returns the same rows: the only writer passes the seat's nodeId, and the event bus fills the node_id column from it. Three test fixtures that inserted these events with a null node_id, which no writer does, now set it as the daemon does.
Slice 22 lane B (0.7.0).
Since #1684, a pi or omp seat whose screen hadn't changed lately read unknown rather than a false idle, so it got no idle-triggered reminders and didn't count as free capacity. This change gives those seats real evidence from the turn events their runner already posts, through the existing activity service, with no new poller.
An idle or attention boundary from the runner now holds until its next event, so a seat that finished its turn reads idle and parked again. A working boundary expires to unknown after the usual hook window, so a dropped end-of-turn can't hold a seat working forever and a claimed row still becomes stalled-after-claim. A runner's exit reads unknown, turn_start reads active, and omp's non-final agent_end stays working. Each runner post carries a sequence number, so an older Stop that arrives after a newer start is dropped, and at daemon start each seat's newest record by that order is restored, so an idle seat gets its reminders back after a restart. Claude and Codex keep their 15-second window.
Two limits are stated in the PR: after a runner dies without reporting it, the seat keeps reading idle until its next event (the follow-up #1702 adds runner liveness), and the restart restore reads each runner seat's whole activity history once at daemon start (not bounded on a large history; unmeasured).
Slice 22 follow-up to #1684 (0.7.0).
rig ps --rig <name> with a name that matches no rig printed "No rigs", "No nodes" or zero totals and exited 0, so a typo read as an empty fleet.
In text mode it now prints "Rig '<name>' not found. List rigs with: rig ps" and exits 1. An archived rig's name gets a pointer to rig ps --rig <name> --include-archived instead. The same holds for --nodes and --summary. --json keeps its empty array, and an existing rig that --filter or --active empties still shows its empty result. The help text's exit codes don't list this case yet; that's a small follow-up.
Thanks to @Nikhi00718 for the fix.
The MCP tools that take a rig sent their argument to the daemon as an id, so an agent using MCP with the rig name it sees got "not found", and rig_down needed an id.
They now resolve it with the same resolveRigHandle the CLI uses, through one helper: an exact id comes first, one active name resolves to its rig, and a name two active rigs share returns isError with the rig_ambiguous object before any request. The tools are rig_down, rig_snapshot_create, rig_snapshot_list, rig_restore, rig_bind, rig_rig_nodes and rig_add.
One result changes: rig_snapshot_list with a handle that matches no rig now returns isError "Rig '<x>' not found" instead of an empty list. A known rig with no snapshots still returns an empty list, and a removed rig's id still lists the snapshots kept for it.
Thanks to @Nikhi00718 for the fix.
A person can now save a mode such as "asleep", "away from desk" or "out of office". A mode holds their availability, their quiet hours and the decisions the team may make for them. They can switch it on, see what it changed, and end it ("I'm back"). Ending puts back only what is still the mode's own change: anything the person changed by hand in the meantime stays as they set it, and an away flag they turned off themselves during the mode stays off.
The commands are rig gateway human mode save | saved | apply | current | end, beside rig gateway human quiet. Each effect shows its real state: active, ended, kept (changed by the person since), pending or failed, and restores still owed from earlier modes stay listed until they're done. The existing rig mode commands are unchanged.
Migration 122 adds three tables (saved modes, applications, per-effect records) and changes no existing table. The registry writers keep a small per-person change record outside the person's file, so a mode can tell its own availability change from a later one with the same value; the file format and older readers are unchanged. The quiet-hours store gains one method that puts back an ended mark, so restoring earlier quiet hours never restarts tonight's quiet if the person had already ended it. A mode never changes permissions, native settings, work posture, lifecycle or the legacy mode bindings.
Refs #1606 and #1649. Slice 04 (0.7.0).
#47 reported a Slack notice silently failing with invalid_blocks when an evidence ref was a non-image https link. #89 (in 0.6.11) fixed that cause in the renderer. What remained, as the issue said, was the handling of an explicit rejection: Slack's invalid_blocks response was retried on every replay, failing the same way each time, with nothing telling the agent that sent it.
An explicit invalid_blocks response (HTTP 200 with that error) now leaves a durable rejection for the exact rendered payload (channel, text, blocks and thread), so replays and restarts don't post it again. The queue item stays undelivered, and the source seat gets one notice saying the payload was refused and must change. A corrected payload is a new attempt, and a newer corrected attempt whose response was lost isn't hidden by the older rejection. Transient errors, 429s and outcomes that can't be confirmed keep their existing retry and reconciliation. In a multipart ask, parts already posted stay posted, and a partly rejected ask is never marked delivered.
Thanks to @Aschenbath for the fix.
The stuck sweep stopped reporting a stuck row while the wake ladder held a marker for it, and that suppression had no end: a ladder that stopped working kept the row quiet indefinitely, and a finding whose problem had resolved could stay open.
Now the sweep records, in each pass, which candidates the ladder actually held back (only for the undelivered-wake and unclaimed-obligation kinds, and only where the ladder check is the reason the row is skipped, after the quiet-hours and already-notified filters), and keeps open only those findings. Every other kind resolves as before. The hold lasts at most the longer of 30 minutes and twice the ladder's configured retry interval (queue.wake_retry_interval_seconds, read once per sweep, with the existing 300-second fallback); an exhausted ladder releases at once, and a missing, invalid or future marker time fails open, so the finding is reported. Human-notice findings are never ladder-suppressed, so they resolve as on main.
Two results change: a ladder that has gone stale is reported again (including the operator rung while it waits on the engine), and a finding with a fresh marker stays open while that marker is current.
Thanks to @DeryFerd for the fix and for three careful rounds.
rig ps --host <h> --rig <name> showed every rig on the host in the rig view: the remote path ignored --rig, while the remote node view (rig ps --nodes) already filtered through the shared handler.
The remote rig view now applies the same name filter as the local view, before --filter, --summary and --limit, in text and --json. For anyone who passed --rig with --host, the output now holds only the named rig, which is what the flag says.
Thanks to @Nikhi00718 for the fix.
When a managed compaction preparation stopped on a failure, automatic compaction stayed off for that seat until someone intervened.
Now the automatic poll path can start a fresh attempt (new attempt id and map) once ten minutes have passed since the stop, after the existing gates; within the cooldown the hold stays. Two cases never reopen: a person's cancel stays final even when it arrives after a failure stop, and an attempt whose /compact submission is uncertain (its receipt was lost) is never retried automatically, because a second compaction before anyone knows whether the first landed is the expensive direction. Only an attempt stopped before /compact was sent is eligible.
Slice 22 lane D (0.7.0).
Before this change, one seat whose required startup file was missing stopped the whole rig's restore: every seat stayed down until the file came back.
Now the restore holds only that seat. It isn't launched, its line in the result says why (the missing file, its path and how to fix it), and the rest of the rig restores, reported as partially_restored. Real rig-wide blockers, and a rig whose seats are all held, still stop before anything launches. rig up --existing prints the held seat's explanation on its line, as it already did for seats awaiting a decision. The as-built restore notes no longer list the missing startup file among the rig-wide blockers.
A follow-up is named in the PR: restore-check still treats this per-seat blocker as rig-level.
Slice 22 lane D (0.7.0).
/api/health (rig health, the TUI's System > Health) and /api/attention rebuild the health projection on every request. Its passive-ceremony source checked each busy handoff family for closed diagnosis boundaries with a separate full scan of queue_items by tag, and built the full row projection for every hit. On Build's live daemon, /api/health?limit=50 took about 2.1 s with about 32 such families; on a copy of the database, one family's scan took about 37 ms and all families' lineage rows read once take about 26 ms.
closedBoundaries now gets its rows from one read per evaluation (qitem_id and tags where tags mention health-lineage:), grouped by exact health-lineage tag, and no longer builds row projections, since it only used their ids. The 200-row limit and its health_passive_episode_history_truncated error, the transition reads and their limit, and the boundary parsing are unchanged. One difference: a row whose tags aren't valid JSON is skipped, where the old per-family json_each query failed the whole source.
Slice 22 lane B (0.7.0).
rig status, the TUI's status refresh, the restore preview, and rig restore or rig up without a snapshot id choose a rig's restore source with findLatestRestoreUsable, and restore also looks for a newer usable alternative with selectRestoreUsable. Both read every snapshot of the rig, data included, to choose one. On a copy of a real install's database, ranking the largest rig's 109 snapshots (17 MB of data) took about 206 ms; it now takes about 0.1 ms.
findLatestRestoreUsable now ranks with an id-only query on idx_snapshots_rig, then reads candidates by id until one decodes and is restore-usable (usually the first); the tolerant decode is kept, the winner is decoded once, and a row removed between the ranking and its read is skipped like an unusable one. selectRestoreUsable finds the newer alternative from id and created_at, reading data only for newer rows and stopping at the first usable one. Selection rules and results are unchanged; a new test pins that the rig-wide ranking reads no data, beside the existing selection tests.
Slice 22 lane B (0.7.0).
rig archive, rig unarchive and rig release sent their rig argument to the daemon as an id, so a rig's name answered "not found".
They now resolve it with the same resolveRigHandle that rig grow, rig remove and rig snapshot use. An exact id, including an archived rig's, comes first. A name resolves among active rigs for archive and release, and among archived rigs for unarchive, whose target is archived; resolveRigHandle takes that as an option, and every other caller keeps matching active names only. A name two rigs share stops with their ids before the request (rig_ambiguous with --json), so release --delete acts by name only on a single match. When the rig list can't be read, the handle goes to the daemon as given, as before. archive's "bring it back" hint prints the resolved id. The CLI reference rows say <rig>.
Thanks to @Nikhi00718 for the fix.
rig restore status <id> --rig <name> --json, given a name two active rigs share, printed the ambiguity message as plain text on stderr and nothing on stdout, so an agent parsing --json got no JSON.
It now prints the same rig_ambiguous object the other rig-taking commands use under --json ({ ok, code, message, candidates }) and still exits 1. Without --json the text is unchanged. The other callers of the shared lookup (export, snapshot, restore apply) don't read the new candidates field.
Thanks to @Nikhi00718 for the fix.
rig ps --nodes printed idle for omp seats whenever their pane had been quiet, while the daemon's own JSON said their activity was unknown. omp had no activity inventory of its own, so it fell back to the generic floor, where window sampling is authoritative and pane silence becomes idle-at-prompt, even for a seat mid-turn waiting on the model or a long tool call.
omp now gets the positive-only sampling community harnesses already have: a changing pane reads working, and a quiet one reads unknown, never idle. claude-code, codex, pi, terminal and stub keep their inventories.
This changes decisions for a quiet omp seat, not only the table: the parked-owner wake doesn't fire for it, the idle gate skips it, and it isn't counted as free capacity. A claimed row still becomes stalled-after-claim and gets its recovery item, and --wait-for-idle keeps its own probe. That trades false idles (wakes typed into busy panes, capacity counted wrongly) for no idle-triggered reminders on omp until runner turn events are read as evidence.
rig ps also printed one "Model (...)" line per seat for runtimes the model reader doesn't support; two or more such seats of one runtime now share one line listing every configured model with its count. A lone seat keeps its own line, and each seat's model is still in --full and the JSON.
Thanks to @rakoort for the report.
When an agent claimed work and then parked it on a person, a human notice that was never posted was reported by the stuck sweep as "wake failed (claimed)" and sent to the agent's orchestrator, which couldn't act on it: the agent wake had worked, and re-waking the agent fixes nothing.
The sweep now files an undelivered-human-notice finding with the human leg, recipient and reason, and routes it to the agent responsible for that notice: the parking agent for a parked row, the asking agent for a direct ask, and the sender of a resolution notice. A stale agent-wake ladder no longer hides it. A genuinely failed agent wake keeps its existing escalation, wording and route, and quiet-hours custody and posted-notice suppression are unchanged. The sweep reports the delivery problem; it doesn't repost notices, re-park work or change what happens to backlog when Slack is enabled.
An already-open "wake failed (claimed)" finding for a row parked on a person stays with the orchestrator under its old wording; treat it as a human-notice failure and forward it to the parking agent rather than re-waking the agent or simply closing it.
Thanks to @rakoort for the report.
rig shrink <rig> <pod> and rig expand <rig> <fragment> sent their rig argument to the daemon as an id, so a rig's name answered "Rig not found" for a running rig. rig grow and rig remove, which make the matching member-level changes, already take a name.
Both commands now resolve the argument with the same resolveRigHandle. An id goes to that id, one active name to its rig, and a name two active rigs share stops with their ids before anything changes (rig_ambiguous with --json, the shape rig remove uses). A handle that matches no rig, or a rig list that can't be read, still goes to the daemon as given. The CLI reference rows say <rig>.
Thanks to @Nikhi00718 for the fix.
rig snapshot, rig snapshot list, rig export and rig restore --rig (and restore status) sent their rig argument to the daemon as an id, so a rig's name answered "Rig not found", and rig snapshot list printed "No snapshots".
They now resolve it with the same resolveRigHandle that rig grow and rig remove use. An exact id, including an archived rig's, comes first; one active name resolves to its rig; a name two active rigs share stops with their ids before any snapshot, export or restore request. When the rig list can't be read, the handle goes to the daemon as given, as before. The daemon's cross-rig guard still refuses restoring one rig's snapshot into another.
One result changes for scripts: rig snapshot list <handle> now exits non-zero with "not found" when the rig list was read and no rig matches the handle by id or active name, where it used to print "No snapshots". A known rig with no snapshots still prints "No snapshots", and a removed rig's id still lists the snapshots kept for it. The CLI reference rows say <rig>.
Thanks to @Nikhi00718 for the fix.
A queue notification left failed or unconfirmed could stay unsent across a daemon restart even while its work was still pending. Recovery now makes one new guarded attempt for each current notification, keeping its original envelope and adding a notice that delivery may repeat after an earlier unsuccessful attempt. A notification that was never attempted gets no such notice. Queue changes and lifecycle actions aren't replayed.
The startup drain owns this recovery, including overdue workflow packets that were nudged before; packets without a recorded intent, and overdue work someone already claimed, keep their ordinary reminder. Claims left by an earlier process are reconciled before this process can start sending, so a send already in flight at startup keeps its claim. When a retry fails before writing anything, a notification whose earlier attempt may have landed stays unknown rather than failed.
Startup selects intents for current pending rows only, so history doesn't add per-row work, and one statement labels obsolete pending intents superseded without a send. Each pass has a fixed horizon and attempts each coalesced member once. Live sending claims, known deliveries and held or retired messages are untouched, and the existing recipient, occupant, prompt and draft checks still apply. Recovery runs at startup only; a large current backlog is sent in sequence, and recovery doesn't wait for a seat's compaction restore.
rig transcript <seat> --tail N printed "note: only N line(s) captured ..." for every N of 5 or less, because the note fired whenever five or fewer non-empty lines were printed. A seat with a long, healthy transcript got it on each --tail 5, --tail 3 or --tail 1, pointing at a renderer problem that wasn't there.
The note now also needs the transcript to have returned fewer lines than were asked for, counting the tail route's default of 50 when --tail is absent or not positive. A thin capture read with the default tail, or with a tail larger than what was captured, still gets the note. Local and --host reads share the check, and --grep is unchanged.
Thanks to @Nikhi00718 for the fix.
rig config reset <key> deleted the key but left the parent objects it emptied in the settings file, for example "transport": {}. The daemon's settings store, the other writer of the same file, behaved the same way.
Both now remove each ancestor the reset leaves empty, with the same function in the CLI's ConfigStore and the daemon's SettingsStore. A parent that still holds a sibling key stays, an empty object elsewhere in the file is left alone, and a keyless reset still removes the file. Resetting a feed-host subscription keeps its own early path, as before. config-atomic-write.test.ts, which runs every case for both stores, now expects the pruned result, and each store has unit tests for the prune, the kept sibling and the unrelated empty object.
Thanks to @arxh80 for the fix.
An agent could write a record after moving a Codex home's seats between subscriptions (rig provider moves record) and read it back (rig provider moves, rig provider moves show), behind the off-by-default provider.subscription_moves setting. Nothing acted on a record: no projection, health check, scheduler, launch or restore path read the event it wrote. Its only readers were its own read-back commands, the event-type union and the setting's declarations.
The module, its three routes, the three CLI commands, the setting (daemon, CLI and TUI) and the event type are removed, along with the CLI reference rows and the what-you-can-do sentence. Subscription moves themselves are untouched: rig provider status, rig provider precheck and rig provider switch, where the daemon runs a precheck-gated switch.
The openrig-user skill changes in two places. Its reporting step now has the agent report the same facts (home and machine, from and to, the reason, the readings, each seat's result with evidence, and the peers) to whoever asked for the move, as a queue item, instead of writing them to the removed record. Its "Manage subscriptions across a fleet" section applied only when the removed setting was on and the person had asked; it now applies when the person has asked, which remains the opt-in. The skill digests are regenerated.
Each startup submission observation carried sha256 digests and byte counts of the expected prompt and the observed composer text, plus the first byte where they differed. Nothing read them back: the startup orchestrator reads only the observation's reason to word its warning, and the consumers of node.startup_ready and node.startup_failed read other fields.
The digests, byte counts and first-difference offset are removed from startupSubmissionEvidence(). The mismatch reason now comes from a plain comparison of the same whitespace-stripped texts the digests were taken over, so its values are unchanged. Each observation keeps its reason, the marker and closing-rule lines, the capture sizes and the phase.
Every submission warning and status is unchanged. Staged or clear, confirmation and the guarded Enter-only retry never read a digest. session-transport.ts isn't edited: its submitOnly refusal and the mismatch callback behave as before, and the callback receives the same evidence minus the digest fields.
rig slack verify wrote a started and an observed line to the channel-operations ledger. The only reader was the Connections projection, which turned the newest verify line into the TUI's "last check" line and could set the displayed state to failed or indeterminate. No delivery, readiness or routing decision read it, and a successful check never made the state ready.
verify no longer writes that receipt, and the projection no longer reads it. verify still checks granted scopes and membership of every mapped channel live, prints READY or NOT ready, exits 1 when not ready, and keeps its --json fields. The other ledger writers (enable, disable, configure, binding) keep their receipts, and lines written by earlier versions stay in the ledger and are ignored.
Two results change. --reason and --actor existed only to fill the receipt and are removed, so a script that passes them now gets an unknown-option error; the CLI reference lists --json only. Connections can no longer read failed or indeterminate because of an earlier verify: it shows unverified unless configuration or the running connector says otherwise, and points at rig slack verify --json. verify also no longer refuses when the ledger can't be written.
rig bundle check sent every kind: local plugin source path to an in-bundle file read, so a plugin declared the way the agent-spec reference recommends, openrig-home:plugins/openrig-core, was reported as a declared file that is missing, unreadable or outside the bundle folder. openrig-home: paths are resolved on the host at launch, and their bytes aren't part of a bundle's digest.
A local source path that starts with openrig-home: now takes the existing host-resolved branch (host_resources: not_checked, checked at launch). A genuinely missing bundle-relative path is still a referenced_files finding; the test covers both. A registry check that runs bundle check can now pass a listed team that uses the recommended plugin form.
The rig seat restart report carried two fields that always read unknown: processMode, and billingRoute, which was filled by scanning the restarting process's environment for API-key variable names. Nothing acted on either: only the CLI's printer and the restart JSON read them, and the restart's finished event never stored them.
processMode and the environment-name scan are removed. billingRoute stays in the plan and report as a constant that says it isn't reported, because the human printer in CLIs before 0.7.0 reads its detail without a guard and would otherwise throw after a restart had already run, hiding the account and recovery lines. A comment marks it for removal once those CLIs are gone; the new CLI doesn't print it.
The restart's real checks are unchanged: the credential-store and auth.json account evidence, the startup gate, the approval, sandbox and bypass comparison before stopping, and --verify --expect-account. The --no-daemon line keeps its app-server caveat.
The review-team skill opened proportionately, then demanded that every claim be verified and that unverified findings go unreported, and its deep method added a context-confidence admission step, every-finding cross-examination and a roundtable everyone had to post to. That turned reviews into rounds.
A material finding now says the user consequence, the affected path and how the reviewer knows (an observed result, source reasoning or an untested concern), and whether this change introduces, exposes or leaves the issue. Severity is kept separate from whether it blocks under the project's own acceptance rules, and a serious untested concern is labelled rather than dropped or turned into an automatic veto. Everyday review opens with two questions: does the change solve the user's problem with a maintainable design, and what about affected contracts, invalid input, state transitions, recovery, consumers and unnecessary duplication. Confidence scores and provider stereotypes are replaced by choosing reviewers for independence, capability and observed work.
For the explicitly selected deep method: reviewers form independent first views from the context and source, the decision owner then asks for evidence or a counterexample only on disagreements that could change the disposition, and the result is one disposition with severity, blocking rationale, evidence limits and any dissent kept visible. There is no fixed number of rounds, no requirement that everyone answer every finding, and preparing context is part of the review, not a gate before it. A protocol the project specifically selected stays controlling. Entry rules and explicit deep-review selection are unchanged; canonical and daemon copies are byte-identical, and the skill digests are regenerated.
The verification skill opened proportionately, then demanded fresh evidence in the current message for any claim, mandatory self-execution and a gate before every status, which turned ordinary progress into reruns. It now has one rule: before claiming a verifiable result, inspect evidence that supports that result for the candidate and environment you name; reuse an existing result while its inputs still hold, and rerun the check when they change or are uncertain; say whether you ran it or inspected another owner's evidence. Required project checks still apply, "tests pass" still needs output for the named candidate, and an agent's success message or a diff alone is still not evidence. The rationalization table and the fear paragraph are removed rather than answered with another layer.
The skills router's one-line summary of this skill now says the same, and its line for the review team names grounded findings. Two short cold references are added: refactoring, linked at the development team's build-and-verify step, and performance claims, linked from verification. They adapt methods from Cursor's pstack plugin, with attribution, and set no run count. Canonical and daemon copies are byte-identical, and the skill digests are regenerated.
A terminal send could be stopped by its own delivery bookkeeping: a reused caller delivery ID, a draft hold that had since cleared, or an unreadable receipt could return an old result or refuse the send before any input was attempted. Receipt readback and the receipt eligibility gate are removed. Each current request makes a new input attempt; the existing per-seat lease still serializes input, and history stays available for held-message inspection as best-effort records that never grant permission to send. The CLI no longer generates deduplication IDs.
Queue wakes are now classified from the transport's own result (sent, input phase and explicit uncertainty reasons), so an interrupted paste or Enter stays indeterminate without a receipt summary. Queue nudges that carry no pre-assigned IDs check their actual queue row and intended destination immediately before input, including when bookkeeping is unavailable; committed wakes keep their frozen identity and blocker checks.
Changed results: reusing a caller ID no longer deduplicates explicit requests; confirmed success stays successful if its ledger update fails, with a warning; a caller abort before paste cancels input even after bookkeeping; a draft that blocks input and can't be saved is reported failed rather than durably held; send responses no longer carry a receipt-derived delivery summary. Human drafts, approval prompts, recipient and conversation identity, and stopped-runtime handling are unchanged, and no refusal is added. docs/reference/seat-delivery.md describes the new behaviour.
The compaction-restore skill told a restored agent when it may resume (after the read-depth table and the reconnect note exist) but not how. It now ends with a short "Then resume" section: resume the outcome the current instructions, standing duties and assigned work select; use existing records and the recap to recover the next action and the evidence already earned; check uncertain effects before replaying work; don't let available skills or historical suggestions widen scope or create required stages; use authorized help within your remit; and route a missing decision to its owner while continuing unaffected work.
It replaces a guardrail that repeated the skill's own hold check, its post-read check for a new hold, and its reconnect step's "if a hold stands, the next step is waiting". Those three still say a hold binds. The skill digest is regenerated.
Four runtime probes ran a blocking execSync inside an async function: the plan preflight, the pod-aware import preflight, the Codex launch and restore profile probe, and the Codex resume probe. Each held the daemon's event loop for the probe's whole run, about 0.4-0.9 s measured and up to 10 s for a hung probe, so health checks and every seat waited behind it. The skills audit also read every SKILL.md synchronously.
The probes now go through one small async helper that runs the same command with the same cwd, environment, 10 s timeout and closed input, under the existing timing sites. A probe that times out still reports ETIMEDOUT. The audit reads with fs/promises, with the same roots, order, shadowing and rejection reasons.
Still synchronous, and not changed here: a named-skill lookup that misses falls back to the full scan, and catalog traversal and YAML parsing stay synchronous (one catalog measured at about 0.1 s per member warm). The two loop-gap tests use a 500 ms threshold. Refs #1653.
Team agents launched with the team permission default were asked to approve 42 rig commands. They're now asked about 29.
These 15 no longer prompt, since none starts, stops or removes a team or agent: archive, unarchive, adopt, attach, bind, unclaim, release, reconcile-session, config set, config reset, seat continue, seat clean, seat switch-client, seat set-model and seat set-effort.
Two gain a prompt: seat restart, which stops and resumes a seat, and policy permissions apply, which is policy apply under another spelling and previously didn't prompt. The commands that start, stop or remove teams or agents still ask. So do import (its --instantiate mode launches nodes), setup (its --policy path records a permission choice), compact (it replaces a peer's working context), and policy apply and seat set-permissions (an explicit permission choice stays with a person).
The list has one source, TEAM_CLAUDE_ASK in kernel-authority.ts, and the tests write out the expected lists instead of deriving them from it. The adapters-and-runtimes reference describes the new behaviour.
The context monitor could act on a readiness snapshot that another reader had already changed, so a managed compaction could proceed past an independent readiness change. It now rechecks the snapshot before compaction and defers when an independent readiness change has landed, while its own readiness writes don't count against it. A seat whose readiness changed keeps its usage telemetry. A two-session regression test covers one seat's change not affecting another's.
Thanks to @Nikhi00718 for the fix.
The test setup left `CLAUDE_CONFIG_DIR` and the passwd home outside the fixture, and exporting `OPENRIG_E2E_REAL_CODEX=1` turned off home isolation for the whole run, so ordinary suites could read or write a developer's real provider homes.
Ordinary suites now stay fixture-scoped even with the real-Codex opt-in exported; the exception is limited to the native Codex end-to-end files. Inherited provider roots (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`) are isolated, while a provider home a test passes explicitly in its `baseEnv` is kept, so scenario environments that set one on purpose still work. Default Codex thread discovery now follows `HOME` (`os.homedir()`) instead of the passwd entry, so it stays inside the fixture too. `docs/as-built/test-layers.md` describes the layers.
Thanks to @AdamWhitehurst for the report and to @Aschenbath for the fix.
A watchdog reminder that was due could be suppressed by its own delivery bookkeeping: an earlier attempt's failed, unknown or delivered record, legacy adoption, or an interrupted digest could make the watchdog return the old result, or skip the whole digest, without calling the transport, so the reminder never arrived.
The watchdog adapter's receipt veto, its legacy adoption and digest prebinding, and the engine's receipt-veto callbacks are removed. A reminder that's currently due now reaches an ordinary idle input prompt with its current continuation, and with a notice that it may repeat if an earlier attempt's outcome was unconfirmed. A digest sends every member that's still due.
What stays: the transport's recipient and conversation checks, approval and draft holds, the current job, park and target-generation checks, scheduler timing and stop controls. Structured continuity actions keep their own idempotence, and an unknown write still reports as unknown. Policy thresholds and conditions are unchanged. `docs/reference/seat-delivery.md` describes the new behaviour.
A parked-owner watchdog wake could tell an agent to resume its task work after its managed `/compact` boundary but before the restore request, so an agent that obeyed would work from its compaction summary before restoring. The daemon already knows it still owes that seat its turn boundary, restore and audit.
The parked-owner policy now defers that owner's pass while the shared compaction enforcer has a pending post-compact stage, from the submitted `/compact` until the audit prompt is sent. It skips before reserving a wake, so nothing is consumed and the unchanged row is eligible again on a later pass; it keeps checking other owners in the same pass. Explicit cancellation and occupant invalidation also clear the pending stage. Preparation before submission and peer sends are unchanged, and there's no elapsed-time guess or new scheduler. A deferred pass is recorded in the job's history as `managed-compaction-pending`.
The check runs when the policy decides to wake a seat. A wake admitted just before a `/compact` is accepted can still wait on the seat's delivery lease and land after the boundary, because delivery doesn't check again; closing that window belongs with the planned rework of how watchdog wakes are delivered and recorded.
Resolving a member used to read and parse every SKILL.md in every skill folder for its runtime, once per member, synchronously on the daemon's event loop. With tens of thousands of skills in a user folder, `rig up <spec> --plan` stopped `/healthz` and every other request for every seat on the machine for 10 to 20 seconds per plan, and launch and restore paid the same cost. Now a member's resolution looks up only the skills its profile names, at `<root>/<name>/SKILL.md`, in the same root order, and falls back to the full scan only when a named skill isn't found that way, so folders named differently from their skill still resolve and a broken named skill gets the same rejection message. One change in precedence: a folder whose name doesn't match its skill in an earlier root no longer outranks a correctly named folder in a later root.
`rig up --plan` now waits 120 seconds, the same as an apply, locally and with `--host`; it used the client's 5-second default, so a plan with slow profile probes reported a timeout with an unknown outcome while the daemon finished it. If the daemon still doesn't answer a plan, the CLI says the plan created or changed no rig or seat and is safe to run again, and exits 1; with `--json` it prints `ok: false`, `plan: true`, `changed: false` and a message. Locally that covers a timeout; with `--host` it also covers a request that may have arrived without an answer or an unreadable reply. An apply that times out keeps its existing guidance and, with `--host`, `outcomeUnknown: true`.
Other work can still block the daemon during a plan: the runtime profile probes (`execSync` inside an async wrapper, up to 10 seconds each), the managed catalog scan, and the skills listing route.
The getting-started page for people (`docs/reference/quickstart.md`) opened with two short paragraphs and then explained how the pieces fit and what you need before showing any step to take, so on a phone the first screen was all explanation.
The section "The quick way: let your coding agent do it" now comes directly after the introduction, ahead of "How the pieces fit" and "What you need". Only the order changes, plus one pointer: the section's "(see above)" now links to What you need, because the Node.js help it points to comes after it. Headings are unchanged, so every anchor still resolves.
A managed compaction whose `/compact` was interrupted (for example, the person kept typing) used to stay at `compact-sent` with an unconfirmed result. `rig compact --cancel` didn't clear it, and every new `rig compact` for that seat was refused as already in progress until the daemon restarted.
Now `--cancel` also disarms an attempt that has already sent `/compact`: the pending turn-boundary, restore and audit steps are cancelled, the attempt keeps its unconfirmed result and its restore-map path, and the cancel sends nothing to the seat. Work from the cancelled attempt that resumes after a wait can no longer type into the seat or bring the stage back. A new explicit `rig compact` then starts a fresh preparation and map, and its `/compact` still waits until the live pane reads idle; a stale idle signal can't stand in for that. Cancelling doesn't start an automatic compaction.
Nothing in the code can tell an attempt that never compacted from one that is compacting slowly, so no expiry or guess was added. If the seat may have compacted, it now gets no restore request on its own: the cancel response and `--cancel` help say to check the pane and, if it compacted or it's unclear, send it a normal restore request naming `preparation.mapPath` (asking it to skip the reading if it didn't compact), because a retry would compact it again.
Every `rig terminal open` with Herdr creates a fresh workspace on purpose. Herdr accepts a duplicate label, so opening the same rig's view three times gave three workspaces all named after the rig, each reported as `ok: true`; the numbered suffix was used only when Herdr refused the label. The Herdr adapter now lists the existing workspaces first and gives a re-opened view the next free number, so each workspace's name is unique. The daemon and TUI tests pin the list-then-create order.
When a post-compaction refocus fails, the next prompt retries it and its label quotes why the last attempt failed. A long reason, such as a traceback from the trace script, used to ride into that label whole. The saved reason is now capped at 300 characters, ending in "…", and the failed attempt's own TRACE GAP line still shows the full text. A test drives a long stderr through the trace and checks both.
Commands no longer refuse a daemon that is busy but alive. The CLI's status check before each command refused whenever it couldn't confirm the daemon was healthy, so right after a slow request an ordinary command such as `rig ps --nodes -A`, `rig capture` or `rig context profile` failed in about 128 ms with "Daemon did not respond — it may be busy or stopped". Now a daemon that isn't confirmed down gets the real request, under its normal timeout. A daemon confirmed down (stopped, stale, or a refused probe) keeps the clear "not running" message and start hint, and a probe the OS blocks and a live sibling home keep their own guidance.
When a write then times out, it says the outcome is unknown and to check before any retry, including in the commands that catch their own errors (context, agent-image, specs, scope and fork). Each write is sent once and never retried. `rig start`'s UI health read is bounded at 5 seconds.
`rig up` and `rig start` start a daemon when one is stopped or stale, or for a live sibling home or a blocked probe, as before. They no longer start one for a busy daemon of this home, which used to get a false "already running", or past an explicit HOME-MOVED marker. GitHub bundle imports still require a verified local daemon, and `rig status --json` is unchanged.
An ask that quiet hours held now reads honestly wherever the queue judges it. The stuck sweep, the wake ladder and the waiting view share one read-only custody reader built from the notes the quiet-hours release writes:
- a held ask reads held until quiet ends, and the held text names that end, not the next quiet start;
- a released ask reads released while its post is in flight;
- a release whose post failed reads as a failed release, and the sweep reports it as an undelivered wake until a receipt arrives, including after a later re-dispatch of the same notification;
- a hold whose release job is gone reads orphaned.
An ask blocked on a person shows its quiet custody as its next backstop and keeps its real blocker. The release job writes one dispatch note per release cycle, so an ask returned to quiet and released again reads released. A custody read that fails reads as held for that pass rather than escalating during quiet.
`rig setup` wrote `set -g history-limit 50000` into its managed tmux block. It now writes the value of a new setting, `terminal.history_limit`: default 10000, an integer from 2000 to 1000000, overridable with `OPENRIG_TERMINAL_HISTORY_LIMIT`. To keep more, run `rig config set terminal.history_limit 50000` and rerun `rig setup`. A later `history-limit` line of your own in `~/.tmux.conf` still wins.
A running tmux server keeps its old limit until it restarts or you run `tmux source-file ~/.tmux.conf`; before tmux 3.7 that changes new panes only. `rig setup --help`, `rig config --help` and getting-started say so.
At 10000 rows an attached pane and the TUI terminal view keep less scrollback than at 50000, and `rig capture --lines N` reads up to that depth. Transcripts at the default `transcripts.lines` of 1000 are unaffected. The setting appears in the settings API, `rig config` and the TUI's settings view.
A queue write addressed to a host's own self-id (`member@rig@<self-id>`, as a reply hint shows it) now reaches that host when the daemon the command reaches has learned that self-id itself. Before, `rig queue create`, `handoff` and `handoff-and-complete` always refused it as an unknown host.
- **Where the learning comes from:** `rig host doctor <alias>` already records the self-id a host reports at its own URL. It now also records that URL. The daemon's shared queue forward reads those bindings from its own home only, and uses one only when the binding's recorded URL equals the host entry's current URL and no conflicting observation was recorded.
- **Precedence:** the daemon's own self-id and local destinations come first, then a registry id, then a declared `hostId`, then an eligible learned binding.
- **Everything else stays refused before any forward:** a binding learned without a URL (every binding from before this change), an entry whose URL changed, a recorded conflict, an SSH entry, or a binding only in the CLI's home. The refusal names the reason and the fix (`rig host doctor <alias>` from the daemon's home, or `--host <alias>`). With no bindings file, the refusal is unchanged.
- **Unchanged:** `rig send`, placement and every other host lookup; there is no resend, retry or new handshake. A matching URL is what makes a binding eligible; it isn't a live identity check.
- **Doctor:** a contradicting observation never attaches its URL to the stored identity, and a recorded conflict stays until the binding is deleted and re-learned.
Bindings learned before this change carry no URL, so they need a fresh `rig host doctor <alias>` from the same home before this applies.
The tests run the compiled CLI as a separate process against real daemon routes and counting remote hosts: an eligible create writes one row; both handoff verbs create one successor and close the source once, and a re-run absorbs; every ineligible case forwards and writes nothing on all three verbs; a dropped answer makes exactly one request with no retry.
Refs #1642
When `rig queue create`, `rig queue handoff` or `rig queue handoff-and-complete` is refused because the daemon doesn't know the host named in the destination or `--host`, the refusal now says what happened and what to try instead. The refusal itself is unchanged: one write, the same exit code and the same JSON on stdout.
- **A qualified hint, on stderr:** when the daemon refuses the exact host token as unknown and this CLI's own host registry resolves that token to a different entry, the CLI names that entry and shows only the substitution (the address form or `--host <id>`, matching what was given; for create, keeping the same `--id`). It says plainly that this is what the CLI's registry here resolves, which doesn't prove the daemon reached through `OPENRIG_URL` means the same host. Nothing is resent.
- **"No row was created", for create only:** printed only after a refusal that comes before any host is contacted or any row is written (an unknown host, an unsupported transport or an unreadable registry when forwarding, or an unknown destination rig locally). It never appears after a timeout, an indeterminate result, a remote or auth error, an internal error, a dropped connection, or any handoff.
- **Not fixed here:** queue writes still don't accept a host's own self-id as an address, unlike `rig send`.
The tests run the compiled CLI as a separate process against real daemon routes and stores, including an alias that names a different host on the daemon (nothing reaches it), a daemon that declares the self-id (one write, no hint), and both handoff verbs (no successor; the source row unchanged).
Refs #1642
While a person has quiet hours set (`rig gateway human quiet set …`), agents' routine asks to them are held instead of posting. When quiet ends, each one that is still current is released on its own. Urgent asks (escalations) are decided exactly as outside quiet.
- **During quiet:** every routine ask that would post is held, in every delivery class. Nothing posts or mentions, with one exception: a post split into several Slack messages whose first message reached Slack before quiet began may finish its remaining messages in its original thread. Escalations post at once, keep away's 30-minute deferral, and post without a mention for someone who is off.
- **When quiet ends** (at the window's end, on `quiet end` or on `quiet clear`): each held ask that is still current posts through the ordinary Slack path, with its full brief and reply link and no mention. An ask whose situation changed gets a visible disposition on its row instead: closed, replaced by a newer notification, recipient changed, or now below the posting threshold.
- **Carried over:** extending or replacing the window releases nothing early, and an ask already in the 4h or daily digest is held when the digest comes round during quiet.
- **When quiet can't be read:** an ask or digest that was already attempted stays in flight; one never attempted is acknowledged and offered again.
- **Outside quiet:** every decision and the digest are unchanged.
Builds on #1584. The tests use the real delivery engine, dispatch buffer and digest flush with a recording Slack transport; native runs and real Slack were not exercised.
On `rig up --existing`, an agent its person provably stopped on purpose now stays stopped until they start it themselves, instead of being given a fresh conversation. The founder chose this behaviour.
- **What counts as proof:** the node's newest session is exited with its deliberate-stop flag set, the same session is absent and exited in the snapshot, its native identity matches, and its newest occupant tenure is an original (`initial`) one. A seat that was restored before still qualifies. An older session's stop never holds a newer occupant, and handed-over, adopted, terminal, detached, legacy, missing-tenure or different-identity seats don't get the hold.
- **What the person sees:** preview and apply both name the held seat and the command to start it, `rig seat launch '<logical>@<rig>' --fresh --reason "<why>"`. With no usable saved snapshot, the preview derives the same occupancy the apply's capture would and gives the same reason, writing nothing.
- **Scope:** only explicit existing-rig up, from the CLI and from Explorer. Unattended startup and direct snapshot restore keep today's policy. `rig up --fresh` still starts a fresh conversation on purpose, and the earlier protection for fresh occupants is unchanged. Other seats come back normally and the partial result keeps its shape. Explorer's fresh preview now forwards its selection, matching apply.
- **Known limit:** occupant tenures carry no session reference and are recorded best-effort, so the tenure is tied to the session by recency; after a failed later adopt or handover record, that occupant could be read as the earlier original one. This needs a tenure-ledger write failure.
No new state or schema. The tests use real SQLite and application routes; native lifecycle behaviour was not run.
Three openrig-core skills now say when an agent can rely on what a queue write already returned, and when it should look at a peer's screen, so agents make fewer redundant calls without dropping a check that matters.
- **After a queue write** (`openrig-user`, with a pointer in `queue-handoff`): use the write's response when it shows what you need; create, claim and update return the row, and handoff returns both rows. Read the row back when the response doesn't show it: an INDETERMINATE outcome or `qitem_body_not_saved` (reconcile by the original ID before any retry), compact or truncated output, a transition note or successor the output omits, or downstream evidence such as the recipient's wake or a later claim. A team rule that asks for a read-back still applies.
- **Checking peers** (`forming-an-openrig-mental-model`): start from `rig ps --nodes` and the queue, and capture a peer only when you need to see its screen.
Unchanged: the check before resending after an ambiguous result, identity checks after restore, handover and boot, held-row checks, the operator-relay capture, the capture before answering a prompt, and outcome and consumption verification. The skill digest file is regenerated by `mirror-skills`. Guidance only; no runtime or permission change.
The refocus hook that runs on every Claude prompt unwrapped a pasted-content envelope with one regex whose overlapping whitespace and wildcard quantifiers sat ahead of an exact closing tag and the end of input. When the closing tag was missing, or text followed it, a long whitespace run made the match backtrack heavily: two 64,000-space prompts ran the real hook past a 2-second kill.
The parse is now linear: an anchored match of the opening tag and its id, an exact check that the prompt ends with the matching closing tag, a length guard so the two can't overlap, then slice and trim. A prompt that is exactly one complete envelope with a matching id is unwrapped as before; anything else is the prompt as given. Codex prompts are unchanged.
Two real-hook cases (a missing close, and text after the close) now finish in about 33 ms under a 2-second process deadline, and the existing managed-refocus controls (wrapped restore requests, mismatched ids, quoted requests, the post-compaction acknowledgement) keep their results.
A `rig send` from a shell outside a managed seat arrives as `<unknown sender>`, and the notice the sender got said to send from a seat shell or state an identity in the body. It now names the two routes that already work.
- **A declared sender:** `rig queue create --source <you>@external --destination <seat> --summary "<one line>" --body-file <file>`. The row records the name as declared (`claimed:v1`), the receiving seat's wake shows it in `From:`, and a person registered with the gateway gets a reply route.
- **Joining a seat:** `eval "$(rig attach --self --rig <rigId> --node <logicalId> --print-env)"`, after which that shell's sends carry the seat's name. `rig attach --help` covers adding the shell as a new member.
Where it's said: the sender notice (it keeps "Follow up and sign it"), `rig send --help`, and both copies of the `rigs` skill, which also say a declared name is a claim, so the attached shell is the route for a team that acts only on seat identity.
Text only; no behaviour changes.
`rig launch`'s description said "Launch or relaunch a node in a running rig", but a node that's already running is left alone ("already running … (not relaunched)"). The help now says what it does, and points a person who wants a fresh occupant for a running seat to the command that exists.
- **The description:** "Launch a node in a running rig, unless it's already running". The help after the options names `rig seat launch <seat> --fresh --stop --reason <why>`, with `--plan` to preview it.
- **The already-running message** adds one line with a command that runs as printed: `rig seat launch <logicalId>@<rig name>` when the rig was named, else the bare logical id. `--json` output is unchanged.
- **The `openrig-user` skill:** its two `rig launch` lines said "relaunches"; they now say it launches a seat that isn't running and name the same route. The skill's recorded digest in `scripts/skill-edge-digests.generated.json` is updated to match.
Text only; no behaviour changes.
A seat added to an existing rig by `rig expand`, `rig grow` or add-member didn't get the rig's `culture_file` or its rig-level `startup` files and actions, because after launch the rig's declaration wasn't stored anywhere the expansion could read. Restore replays each seat's stored startup context, so the gap stayed on every later restore.
- **Stored at creation:** migration 121 adds `rigs.startup_layers_json`, holding the spec's `culture_file` and rig `startup` block as declared, plus the absolute root they resolve against. It's written when a rig is created from a spec, the same way the rig's permission policy is stored and inherited.
- **Used for added seats:** when the launching spec declares no rig layers (an added seat), the stored ones are used, resolved against the stored root, on the agent path, the terminal path and first-start retry. Seats declared in the original spec are unchanged. Pod and member files still resolve against the launch's own root.
- **Restore:** a seat added after this change carries the entries in its stored startup context, so its restores keep them.
- **Existing rigs:** nothing is backfilled. Their added seats keep today's behaviour until the rig is next created from its spec.
Not separately tested: the create branch of re-materialization, the terminal path, first-start retry, a stored root that no longer exists, an existing database's migration and a completed restore. The tests cover original launch, then expansion and add-member, then the persisted startup context and the Claude guidance projection. `rig export` dropping these layers is a separate issue (#1631).
When a post-compaction refocus fails (a trace gap, a content reference that can't be read, or an output write error), it stays pending and is retried on a later turn. The retry used to be labelled "just compacted — your picture is lossy", so it read as a second compaction and didn't say why the first attempt failed.
It's now labelled "retrying the post-compaction refocus; the last attempt failed: <reason>", for example "trace gap: trace exited 1".
- **The reason** is saved in the refocus state as `pendingFailure` when an attempt fails: the trace's reason first, then a content-reference failure, then the output write error. A successful refocus and a new compaction both clear it.
- **Compatibility:** an older state file without `pendingFailure` keeps the old label until its next failure; a corrupt state is a new baseline, as before.
- **Unchanged:** the trace text, content precedence, thresholds, budgets and the other labels.
A rig spec's `services.wait_for` TCP target used to be probed with `nc -z`, so on a machine without `nc` every TCP wait failed. A numeric port is now probed with Node's own socket (connect, idle timeout and error handling, with the socket cleaned up; IPv6 brackets and host names supported).
- **Named ports** such as `db:postgresql` keep exactly today's `nc` command where `nc` exists. That includes names starting with a digit, such as `9pfs`, as long as they contain a letter and don't start with `-`. Without `nc`, a named port fails readiness with "uses a named port, which needs nc on PATH. Install nc or use a numeric port".
- **A malformed target**, such as `host:0`, an out-of-range port or an empty host, now says "is not host:port with a port from 1 to 65535 or a service name" instead of reading like a closed port.
- **An unreachable target** keeps the plain "TCP probe failed: <target>".
Both launch and restore readiness reach the probe through `evaluateWaitTargets`.
When a Claude seat's startup prompt can't be confirmed submitted, OpenRig holds back the extra line that asks the agent to run its startup check, so nothing is typed over input that may still be in the box. The warning used to read "Startup proof instruction was not sent: the startup prompt was not confirmed submitted", which looked like a failed start.
It now says that only that one line was held back, that the startup prompt itself was sent, and why its submission couldn't be confirmed: the prompt still looked staged in the input box, the input box couldn't be observed, it didn't clearly show the prompt as submitted, or the guarded Enter retry didn't go through.
The hold-back condition is unchanged; this changes only the warning's text.
`rig config get <key> --with-source` printed only the value. `--with-source` is the parent `config` command's option, which Commander accepts on either side of `get`, but `get` never read it. It now reads it from the parent's options, as it already did for `--json`, and prints the same `value<TAB>(source: …)` line as `--show-source`, in both flag positions.
`--json` is still checked first, so `--json --with-source` prints the JSON object as before. Plain `get`, `--show-source` and `--json` are unchanged. A new test drives the real command parsing in both positions.
A queue wake used to drop the input-box reading returned by its verified send. The wake now keeps it, and `rig queue show --json` exposes it as `lastNudgeInputBox`, so a wake whose exact text is still in the input box can be told apart from a busy seat or a collapsed paste.
- **Storage:** one nullable column, `queue_items.last_nudge_input_box` (migration 116, registered between 115 and 117), holding only the four structured fields. Every recorded attempt replaces the reading or clears it when none was returned, and delayed and coalesced wakes keep their own send's reading.
- **Latest attempt only:** the stored reading carries its attempt's timestamp and reads as unknown unless that is still the row's latest attempt. So after a downgrade and re-upgrade, an older daemon's later attempt can't show an earlier attempt's reading.
- **Malformed readings:** a reading with a value outside the known seat states or reading kinds, or a non-boolean `sameProcess`, is dropped on write and reads as unknown.
- **Unchanged:** delivery results, pickup and claim presentation, wake scheduling, retries and closure. Compact lists elide the field.
The reading is diagnostic only. It isn't covered by a native terminal run; the stub runtime can't produce verified input-box evidence.
Refs #1278.
Three daemon tests waited only for a shell fixture's output file to exist, then read it. A redirection or `writeFileSync` creates the file before writing it, so a read in between saw an empty file and failed intermittently.
- **`tmux-agent-environment.test.ts`:** `waitFile` keeps its bound but returns only once the file ends in a newline, which all five writers now print last, and it throws on timeout. A fake-timer case checks that an empty or partial file doesn't resolve it.
- **`claude-executable-foreground.test.ts`:** the recorder writes its JSON to a `.part` file and renames it into place.
- **`transcript-seat-env-native.test.ts`:** the fixture writes its environment to a `.part` file and moves it into place.
Test-only; no runtime file changes, and every payload assertion is unchanged.
A post-compaction restore or audit could stay owed while fresh context samples stayed above the trigger threshold, since restore reads can push usage over a low threshold themselves. The enforcer now advances the existing owed stage before the new-trigger threshold and dedup checks, using the same authorization, occupant-generation and idle and delivery guards.
- **Readiness:** for a missing sample or one at or above the threshold, the stage's send uses pane-only readiness, so no hook authorises it. A busy, timer-less or unreadable pane keeps the stage pending until the pane itself reads idle.
- **Unchanged:** below-threshold and ordinary sends; boundary, restore and audit ordering; a refused or retained send leaves the same stage pending; the cooldown and the sample-less path.
- **Changed results:** a fresh high sample can now deliver an already-owed stage instead of returning trigger dedup, and can surface the existing stale-generation result sooner. It doesn't start a second compaction.
**Limit:** an audit completed on a high sample doesn't re-arm automatic compaction, so after a restore that lands at or above the threshold, usage must fall below it before a new trigger. The tests drive the real context monitor, stores, enforcer and transport over inert tmux observations, not a native Claude compaction.
An open `rig tui` refreshes through the rig summary, each rig's nodes and recent transitions, and `rig ps` uses the same inventory read. Three reads behind those routes scanned far more than they return. Each now reads through an index that already exists, with no schema change.
- **A held seat's later restores:** the read now states the restore index's own condition, so SQLite uses it instead of reading every later event of the rig. The two-type filter is unchanged, and a later `restore.outcome_reconciled` still leaves a hold in place.
- **The latest snapshot per rig:** the subquery runs once per rig that has snapshots, not once per snapshot row.
- **Instance-scope recent transitions:** the scope matches each session column on its own covering index and reads ids from the primary-key index (`UNION ALL`, since a `UNION` makes SQLite scan the table to merge in rowid order).
On a copy of a long-lived install's database (1.55M events, 27k queue items), the held-seat read went from 2.25 s cold to 0.3 ms, and recent transitions from 19,897 pages to 913. Old and new queries return identical rows on that copy, and new tests pin each plan from the statements the reads prepare.
Not changed: `rig seat clear-attention`'s restore-history read and the rig status route's fresh-launch read, which don't run on a TUI refresh of a running rig.
The workflow startup sweep's result field `lostNudgesReissued` becomes `lostNudgeRecoveryAttempts`, and its log line reports "lost-nudge recovery attempt(s)". A retained, delivered or terminal wake intent can go through recovery without sending anything, so the old name and text overstated what happened.
- **The count:** one per pending packet with no recorded nudge attempt, counted just before it's routed through recovery.
- **Unchanged:** delivery behaviour. The result type is internal to the daemon, and startup only logs it.
- **Tests:** the seven durable-intent states assert the new count and log text, with a send only for the pending state.
Recurring watchdog policies now include the job's last fire time in a delivery's identity, so each cadence gets its own receipt. A later fire with identical text is sent instead of being treated as a repeat of the first delivery.
- **Condition-based occurrences** (a policy that records a condition receipt) keep the condition-derived identity, so a stop between recording the evaluation and recording the condition receipt doesn't send again.
- **Unchanged:** periodic-reminder and context-usage-threshold identities, parked-owner suppression, and the park-entry continuation text.
- **Upgrade:** a recurring job's old-format identity is carried separately. Recovery adopts an unfinished old-format attempt only for the cadence that is recovering it, binding it through the existing outbox tags, and a later cadence gets a new identity.
**Limit:** an unmarked pre-upgrade receipt is adopted once, so one due cadence after upgrade can consume an already-accounted old receipt instead of sending. Later cadences use new identities. No new timer, retry, schema or store.
The fleet-wide inventory read behind `rig ps` and `/api/rigs/summary` (and so each `rig tui` refresh) runs `readReadyFreshLaunchFences`. Since #1527, SQLite planned its `JOIN events … ORDER BY e.seq` as a full scan of the events table: on a long-lived install that was about 75,800 pages per call, 7.5-10.5 s cold and about 0.12 s warm. The per-rig form was already indexed.
`CROSS JOIN` keeps the same inner join and forces SQLite to read `events` through `idx_events_node_type_seq` for each current tenure: about 110 pages and a few milliseconds, with the same rows in the same order. The fence logic is unchanged.
A new plan test captures the fence statements that the fleet and per-rig inventory reads actually prepare and checks that both seek `events` by node and type; it fails on the old query.
The model-divergence check could not verify a Claude seat launched through a shell wrapper: the identity verdict recorded the Claude child's PID, and the current-model reader compared it with the pane's root PID, so every such seat read UNCHECKED.
- **Both PIDs are recorded:** a nullable migration (119) adds the pane root beside the matched native child, written by both the periodic identity reconciler and the rebind verifier. `evidence.observedPanePid` is a new additive field.
- **The reader:** keeps the pane-root equality guard, and when the matched process is not the pane root, requires it to still be a Claude descendant of the current pane root. The generation, boot-time, binding, sidecar, ambiguity and predecessor checks are unchanged, and there is no newest-transcript fallback.
- **Effect:** supported wrapper and child pairs now reach the existing model comparator (MATCH or DIVERGED) instead of UNCHECKED. A replaced or moved child, or a replaced pane root, still gives no answer.
- **Upgrade:** existing verdicts read the new column as empty and keep the old check until the reconciler refreshes them; nothing is guessed from the child PID.
Settling once per generation in the divergence monitor is unchanged.
The sweepers (the context monitor's startup normalisation and the send/capture reconcile) now pass a Claude seat's real binding (pane, session, current occupant) and its recorded resume token to the Claude adapter.
- **After a daemon restart, or with no launch record:** a seat left in `attention_required` or `failed` on an auto-mode screen with no "Claude Code v" banner is proved the way `rig seat clear-attention` proves it, and the existing status flip promotes it, without a person clearing attention.
- **On the same daemon:** because the monitor now passes the real binding, a seat whose launch record still matches can also be promoted by the monitor through the existing same-daemon launch proof.
- **A requested-token hold:** when the seat's latest `node.startup_failed` names a requested resume token, a promotion that this no-launch proof makes is held if the observed process is on another conversation. It applies only to such promotions (`provedWithoutLaunch`).
- **Unchanged:** existing screen results, the pending startup-text gate and the transport recovery gates. `continueStartup` is not changed, since delivering saved startup text is a different consequence from changing a status.
**Cost and limits:** for a flagged, banner-less Claude seat with no launch record, each sweeper check makes two process listings and two pane listings, plus up to three PID reads per attempted fallback, and repeats for a seat that keeps failing. There is no process-reader deadline, a transport timeout does not cancel it, and recovery within 30 seconds is not guaranteed. The shared process lister may resolve executable paths.
Refs #1477.
When a parked row's `--wake-after` timer fires, the wake now shows what the owner recorded it would do next, so it doesn't have to open the row to find out.
- **A lone park wake** adds `Recorded continuation: <the continuation, on one line>` and one line of guidance: check the row's current state and blocker first, then resume only that work, or park it again with a wake.
- **A long continuation** is cut at 300 characters and says so, naming `rig queue show <id> --full` for the rest.
- **A digest** (several parks for one seat due in one scheduler pass) previews each row under its own line, then gives the guidance once.
- **Only the current park's continuation shows;** an earlier park's plan never resurfaces.
- **Unchanged:** a park with no recorded continuation, an explicit wake message (sent verbatim, outside digests), other reminders, and timing, receipts, supersession and digest accounting. The stored timer message stays the default, so digest eligibility is the same.
- **After a daemon restart,** a recovered wake keeps the body it was bound with and types nothing new, and its history records that body.
The preview text lives in `park-wake-text.ts` and is added at send time.
After a fresh-launched seat is deliberately stopped, `rig up <rig> --existing` can now restore its siblings from the newer, accurate pre-down snapshot instead of refusing.
- **The stopped seat stays stopped,** including with no resume token or a `relaunch_fresh` policy. It is reported as awaiting an explicit launch decision.
- **The exception is narrow:** it needs the current fresh-generation relation, the deliberate-stop flag and the exact exited session and native history, with the seat explicitly absent in the snapshot. Stale, foreign and ambiguous snapshots keep their refusals, an earlier all-running snapshot is never chosen, and the saved snapshot format is unchanged.
- **Daemon start** still restores only a kernel whose seats are all detached, so a kernel with a deliberately stopped seat is not brought back automatically.
- **Wording:** the mismatch message now describes current occupant state ("does not match current occupant state") rather than assuming an older occupant. The refusal status and code are unchanged.
The same shared check drives snapshot selection, the `--plan` preview and the restore itself.
Test only. Four `rig chatroom wait` cases in `chatroom.test.ts` check how an HTTP error from the daemon (404, 500, 503 with an empty or null body) is reported. They used a 0.05-second `--timeout`, which could expire before the stubbed error was reported, so the test sometimes saw the timeout instead. They now use 30 seconds, so each ends on the stubbed error. Fractional deadlines are still tested by their own cases.
An idle `rig tui` now reads the daemon much less often while its activity stream is connected.
- **Activity pushes:** a burst of activity events leads to at most one refresh per 15 seconds, plus one trailing refresh so the last change still shows.
- **Quiet refresh:** every 5 minutes while the activity stream is connected; every 30 seconds after the stream drops or while it is unavailable, as before.
- **Freshness mark:** the page reads as out of date only after its quiet window plus 30 seconds (60 seconds without the stream, 5.5 minutes with it). A failed read, a read error or a dropped stream still marks it at once.
- **Asked-for refreshes** (navigating, actions, a daemon start) still run immediately.
- **With #1600:** when the TUI and daemon builds differ, no background read runs, a waiting activity refresh is cancelled, and later activity events do not refresh.
TUI only: no daemon change and no new refusal.
Test only. In the real-shell foreground test, the pane hook wrote its status file before the parent and lookup files, and the test read all three as soon as the status file appeared, so a run could read an empty file. The hook now saves the exit status first and writes the status file last, and the test waits for a complete status line before reading the other two. It covers bash, zsh, dash and ksh as before.
Test only. The `tmuxControlExec` case "fails a chain's write whose client ended after an earlier command, without writing the rest to it" raced a 50 ms reply deadline against the test's 50 ms poll, so it could fail with "no reply within 50 ms" instead of the client-ended error. It now runs with a frozen clock (`now: () => 0`), as the load-buffer case already does, and asserts the same outcome.
A `rig tui` left open through a daemon upgrade or restart now notices when its build differs from the daemon's, and stops reading in the background.
- **On the first connect and every activity-stream reconnect,** the TUI compares its build (the version and commit `rig tui` passes it) with the daemon's `/healthz` `semver` and `commit`.
- **Same build:** nothing changes.
- **Different build:** the status line leads with the action, for example "Restart rig tui: daemon is now 0.6.12 (9e5382dd); auto-refresh off.", and fits an 80-column terminal. The quiet refresh and the activity-stream refreshes stop until the TUI is restarted; a refresh the person asks for, by navigating, still runs.
- **Unknown:** an unstamped TUI (a development build) or a `/healthz` that can't be read changes nothing; a failed read retries twice, 15 seconds apart.
TUI only: no daemon change and no new refusal. An old TUI's reads were the cost a long-open TUI put on a newer daemon; reading less while idle on the same build comes separately.
Daemon start restores a managed kernel whose terminals were lost (for example after a reboot), while deliberate stops stay stopped.
- **Startup:** a kernel whose seats are all detached and still bound to their newest sessions is restored in the background through the same snapshot selection and restore pipeline as `rig up kernel --existing`. Live, partly stopped, unclaimed and otherwise indeterminate kernels are left alone, and `OPENRIG_NO_KERNEL=1` stays authoritative. Other rigs keep their explicit start behaviour.
- **Status and `rig start`:** kernel status tells a restore in progress from one that finished and derives readiness from the seats actually running. `rig start` carries on with other rigs while the kernel restores, and exits unsuccessfully with a recovery command if the restore finished without a ready kernel.
- **A stop wins:** `rig down kernel` during the startup restore waits, then stops what the attempt left, keeping the deliberate-stop record. An explicit `rig down` of any rig whose tmux server is gone starts an empty server and probes again, and records the stop and clears the binding only when the session is positively absent; it may leave an empty tmux server running and never launches an agent.
- **Joining:** an equivalent explicit `up`, including one that repeats the saved non-interruptive choice, joins the automatic attempt during or after it while its launched occupants still hold their seats, rechecked after every wait. The Explorer up route shares this.
- **Explicit overrides:** `up --fresh <seats>` replaces only the named seats still held by startup's sessions; after an operator corrects a seat's conversation target, an explicit `up` or `restore` replaces that seat; a different non-interruptive choice after startup launched agents is refused, explaining that the rig must be stopped and the command rerun.
Built and carried through five review rounds by @haralez.
`rig seat restart --verify` checks that the restarted seat came back on the expected account by counting new `Account:` lines in the pane. tmux trims old history (10,000 lines by default), so counting from a fixed position could miss or invent a move. The check now records where its baseline sits:
- **Captures that line up:** the baseline counts from the anchor's known position, so trimmed history can't hide or fake a new Account line.
- **Readable captures that don't line up:** it records a whole-pane baseline and compares the whole pane, as before this change.
- **An unreadable capture** stays unknown and verify reports it as not observed.
Process identity, the completed tool turn and the expected-account checks are unchanged. Older restart events keep the old comparison. A whole-pane comparison can still miss a real move after heavy trimming. `capturePaneHistoryParts` reads history size, history and the whole pane in one chained tmux call.
Pi seats opened in Herdr through nested tmux attachments now carry the `HERDR_AGENT=pi` hint, so Herdr can recognize them in its Agents pane, as it already does for Claude Code and Codex. The shared layout helper previously supplied hints only for Claude and Codex. Existing attachment commands, quoting and fallback behaviour are unchanged.
The composed-view regression now includes Pi and an unknown-runtime seat beside the existing Claude, Codex, terminal and blank-pane cases.
A person, or the operator agent acting for them, can set quiet hours and read them back:
- `rig gateway human quiet set <person> --daily HH:MM-HH:MM --tz <zone>` (may cross midnight) or `--until HH:MM`; `quiet show`; `quiet end` ("I'm back": ends the quiet that's on now, tomorrow's rule stays); `quiet clear` (removes the rule).
- **The read-back** gives the rule, its timezone, who set it, whether quiet is on now, and the actual next boundary, correct across midnight and daylight-saving changes. A window that falls wholly inside a skipped hour is skipped rather than advertised as the next start.
- **`rig gateway human list`** shows the person's availability (`available`, `focus`, `away` or `off`) and a quiet line; if the daemon can't be reached, the quiet line says so.
- **Without `--tz`**, a successful `quiet set` says which zone it stored and suggests `--tz` for the person's own zone.
Storage is one quiet window per person (migration 118) with a revision, so later work can tell a stale schedule from the current one. This change only stores and shows quiet hours: delivery doesn't act on them yet.
The missing-pane repair (`rig reconcile-session --repair-missing-pane`) is named where people meet the problem:
- **The compaction refusal** for a protected seat with no recorded pane says both fixes refuse while typing protection is on, how to turn it off and back on with the person's agreement, that the repair keeps the session and resume token, and that plain reconcile adopts into a new session row and re-captures the resume token when it can, with conversation continuity unverified.
- **The send refusal** for an unavailable or changed pane names the repair.
- **The as-built CLI reference and the `openrig-user` skill** list `--repair-missing-pane`; the skill's recorded digest is regenerated.
- **`rig config --help`** lists `transport.local_user_guard`, and **`rig ask --help`**'s exit codes cover the `--wake` refusals.
Text and help only; no behaviour change.
Each proof-source watch evaluation reread and parsed a shared ancestor policy once per sibling slice. It now reuses the existing policy reader within that one evaluation, then discards it. Later file events still get a fresh reader; watch boundaries, debounce, judgment checks and evidence reads are unchanged.
The regression drives the watch callback over two missions with eight slices each and compares its revision and invalidations with the uncached readiness result: a shared project policy read goes from 16 to one per event, and a mission manifest from nine to two. Separate cases cover policy edits and overrides, deletion and recreation, a malformed policy, read errors and recovery, and accepted-evidence changes.
This reduces measured fixture work; production latency is not measured here.
Self-attached tmux members get a routable team address.
- **Sender and reply:** `rig attach` inside tmux records the display name as the member label and returns the declared team address (`senderAddress`, and the returned `OPENRIG_SESSION_NAME`) while the physical tmux name (`0` or a custom name) stays bound. Delivery, capture, history, whoami, queue ownership and context resolve the team address to the current physical name through one shared rule, so a reply reaches the same pane.
- **Clashes:** a new member whose address equals another current member's, or another member's saved session address (unbound or historical included), is refused before labels, binding, provisioning or events change, with `address_conflict` and a request for another member name.
- **The same seat continuing:** a replacement generation of the same rig name and member (its predecessor archived, or its latest session exited, detached or superseded, with nothing live) is not a clash, so YAML re-import replacement keeps working. The delivery guard follows the same rule.
- **The delivery guard agrees with routing:** the guard's target must be the node current routing selects, so an unrelated exact binding stays ambiguous instead of becoming the guard's target.
- **History:** saved records aren't rewritten; an old exact address still reads the old session's transcript, context and native conversation.
Builds on #1200 by @Aschenbath, whose commit is kept unchanged with its authorship, plus one commit for ksh and the dash and ksh test coverage.
An unmanaged Claude launched through a real executable now runs in the foreground of its pane in bash, zsh, sh and dash. The pane reports Claude instead of the shell, so readiness reads correctly for fresh, fork and both resume paths, and the launch's arguments and environment reach Claude unchanged. Aliases, rc functions and wrappers keep their behaviour. Managed launches, Codex and Pi are untouched.
ksh keeps the earlier launch form: ksh doesn't export a leading assignment passed through a function, so the foreground form would drop seat variables such as OPENRIG_HOST, OPENRIG_OCCUPANT_GENERATION, CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN and OPENRIG_RESUME_LAUNCH.
The real-shell test (claude-executable-foreground.test.ts) now also runs its cases in dash and ksh panes.
Refs #1200
Refs #1199
Co-authored-by: Nash Aschenbath <[email protected]>
The capability map that agents read through `rig context get onboarding-width/public-what-you-can-do.md` now carries what 0.6.13 and 0.6.14 added, on main too. Main's copy stopped at the 0.6.12 delta, because each release's map lines are written on its release line. The marker moves to `capability-delta-v0.6.14`.
The added lines cover observation (`rig ps --nodes`'s observed model, roster `show` and `find`), sends and captures (`caller_cancelled`, capture's input-box note), seats and rigs (`rig ask --wake` limited to Claude and Codex, the dotted-name refusal, name-or-ID for `add`, `grow` and `remove`, `rig seat effort` for Pi), Slack catch-up after a connection gap, recovery and compaction (`rig reconcile-session --repair-missing-pane`, manual `rig compact`'s 180-second budget, the refocus work packet switch), `/healthz`'s `euid`, `transport.local_user_guard`, and `rig provider status`'s retained readings.
The reconcile-session line is worded for main: plain reconcile adopts into a new session row and re-captures the resume token when it can, while conversation continuity stays unverified, and both forms refuse while typing protection is on.
Text only; no CHANGELOG, whats-new or version changes.
ROADMAP drops the Slack channel map item (#192): one channel per seat, keyed by channel ID. It's finished, and #192 is closed as completed.
One deleted line; no new roadmap text.
Guidance for the operator and the rigs skill, from first-user runs.
- **Ask before starting a team:** the operator doesn't start a team, including `rig up`, without the person's yes to what it will start. A yes they already gave for that same launch counts, so they aren't asked twice.
- **Approval prompts, grouped honestly:** when a person relays many one-time approvals, the operator passes on only the person's own chosen answer. It re-captures the prompt before relaying, never reuses an answer once the prompt has changed, checks the effect afterwards, and never approves on its own or changes a permission mode.
- The packaged copy of the rigs skill matches its source.
Workflow recovery after a daemon restart respects a wake's durable delivery state.
- **Before:** `runWorkflowBootSweep` nudged pending frontier packets directly through `maybeNudge`, before startup reconciled and drained their committed wake intents. A pending intent could be sent twice, and an abandoned or finished attempt could be replayed outside its original delivery ID.
- **Now:** `QueueRepository.recoverLostNudge` goes through the packet's existing wake intent (`deliverWakeIntent`) when there is one, and keeps the legacy nudge for older packets without an intent. An overdue packet already recovered earlier in the same pass gets no second nudge; overdue reporting and exception admission still run.
- **Tests:** a regression with the real SQLite repositories, boot sweep and intent drain in startup's order, covering each intent state and legacy packets with and without an outbox.
Contributed by @Nikhi00718.
New agents no longer pick up unrelated values stored in the tmux server's global environment.
- **Default:** new managed agents and handover successors start from the daemon-selected launch environment plus the seat's declared values. Values named by tmux's `update-environment` list (SSH agent and display defaults included) are kept from the daemon; stored server values aren't copied. Generated pane identity and the selected auth and endpoint forwarding are kept.
- **Opt back in:** `terminal.inherit_tmux_environment=true` (default `false`) restores inheritance for setups that rely on server globals. Setup gives informational advice when it detects a custom tmux configuration, without changing the setting.
- **Unchanged:** shell startup and custom default commands still run; running agents and server globals are left alone; existing-command restarts without a new seat environment behave as before.
- **If preparation fails** (Windows, or shell or option inspection unavailable), the previous launch environment is kept with a warning in fresh launch results and the daemon log. It never refuses a launch.
Refs #1178.
Codex seat identity is kept, and a Codex resume is proven from Codex's own records.
- **A transcript-less SessionStart:** for a Codex seat, an explicit null transcript path no longer clears the saved conversation, and a generation registered for the node but not current gets `409 generation_mismatch`. A path, an absent key, an empty or malformed value, and Claude, Pi and OMP keep today's behaviour.
- **Resume proof:** with a conversation to prove, the adapter observes the pane's Codex process, reads Codex's records for that process and start time, and re-observes it. A match is resumed. A mismatch is attention naming both conversations. Anything unknown at the window's end is attention with the reason. There's no restart, discard, fresh fallback or longer timeout.
- **Restore join:** the proven process is carried through startup to restore, which compares it with the joined pane's process and reports `attention_required` on a difference.
- **One proof-decision deadline:** late wrapped poll and proof values, or a late match or mismatch, are never accepted. Sleeps request at most the time left. Work already started isn't cancelled, so return can be late.
- **In-place restart:** an unproven resume ends `incomplete` with inspection-first guidance (`rig reconcile-session <seat> --no-launch`), and a mismatch names the conversation that opened. Nothing is retried.
Two members in different pods can derive the same session name (pod `a-b` member `c`, and pod `a` member `b-c`, both become `a-b-c@<rig>`). Today validate and plan pass and `rig up` then half-starts the team.
- `rig spec validate` and `rig up --plan` now warn about the collision, naming the shared address, in human and JSON output.
- Nothing is refused: import, `rig up` and a one-member launch behave as before.
- Tests pin the warning and the unchanged launch behaviour.
Contributed by @jayhemnani9910.
Follow-up tests for the send-during-compaction notice.
- **preparing:** the preparation prompt was sent and `/compact` not yet: the exact stage and notice.
- **compacted:** compacted, restore not yet requested: the exact stage and notice.
- **after_send:** nothing recorded when the send starts, a compaction recorded by the time it finishes: `observed: after_send` and "when this send finished".
Each send is still delivered. Test-only.
A Claude agent stayed marked as needing the person after its permission prompt had been answered.
- A PreToolUse relay and the visible screen (not scrollback) now tell OpenRig the prompt resolved, so the attention mark clears as soon as that's seen.
- In the one case it can't yet tell, an ordinary message to the agent waits until the screen shows the prompt is gone or Claude's turn ends.
- Tests use a recorded Claude 2.1.300 permission screen and cover the clear, the wait and the held-wake escalation.
Contributed by @zichen0116.
Several lookups read whichever rig matched a name, so an archived rig with the active rig's name could answer instead.
- `rig ask`, `queue create --target-repo`, workflow role resolution, `rig whoami --session` and `rig down`'s restore hint now follow the existing active-over-archived rule: the active rig wins, two active rigs with one name stay ambiguous, and a session still running on an archived rig is still found.
- A queue item's repo check uses the running rig's repos, which the maintainer approved.
- Tests cover each lookup with an archived same-name rig.
Contributed by @jayhemnani9910.
`npm test` builds only the daemon before the workspace tests, so on a fresh clone the scenario tests ran `node <missing rig bin>` and failed with MODULE_NOT_FOUND.
- `spawnScenarioDaemon` checks that the rig bin exists before spawning, and if not, says the CLI isn't built, naming the path and `npm run build` at the root.
- A test pins the message and that nothing is spawned.
Contributed by @PROACTIVA-US.
A fake-IP or captive resolver answers every name, so the real-DNS `.invalid` case tested the machine's resolver rather than `resolveToIpOrNull`.
- The unresolvable case stubs `dns.promises.lookup` (the property the function calls), so the rejection still goes through the function's own handling; the `{ all: false }` call is pinned.
- A new case covers the function's 5 s timeout with a never-settling lookup and fake timers.
- The real-DNS `localhost` case is kept. Mocks and timers are restored in `finally`.
Test-only. Contributed by @PROACTIVA-US.
`npm test` runs `mirror-skills.mjs --check`, which calls rsync, so a fresh clone without rsync failed with a bare ENOENT.
- Every rsync call goes through one wrapper that maps only ENOENT to a message naming rsync and its install commands (Debian/Ubuntu, Fedora, Alpine), with the original error as its cause. Anything else is rethrown unchanged, and the arguments and stdio are byte-identical.
- CONTRIBUTING lists rsync beside Node and tmux.
- A test pins both the missing-rsync message and the unchanged rethrow.
Contributed by @PROACTIVA-US.
The compaction enforcer writes preparation files under its OpenRig home, so a fixed /tmp home collected state across runs and could fail when that path was a file, belonged to another user, or was shared by a concurrent run.
- One `mkdtemp` home per run, removed in `afterAll`, replaces every `/tmp/openrig-test-home` and `/tmp/h`, including the asserted prompt paths.
Test-only. Contributed by @PROACTIVA-US.
Two fixes from first-user runs.
- **Node.js on a standard-user Mac:** the quickstart's "What you need" now gives the nvm route with its commands (`touch ~/.zshrc`, nvm v0.40.8's install line, a new terminal, then `nvm install 22`), with no administrator password for nvm itself, and says to run `xcode-select --install` if nvm's installer asks for Apple's command line tools. The README and getting-started name the same command.
- **Shell blocks that paste cleanly:** a trailing `# …` note in a shell block becomes arguments to the command in a Mac's default zsh, so those notes in the quickstart and README now sit in the sentence before their block.
- **No sandbox-only paths for the person:** the rigs skill (both copies) says that a file or command given to the person for their own terminal uses a path that exists on their side; a file a sandboxed agent writes under `/tmp` may not.
## What a user gets
A Codex seat whose plugin selection includes `openrig-core` can now run the plugin's refocus hooks (UserPromptSubmit and
PostCompact) and its activity relay. Until now Codex never loaded them: OpenRig copied the plugin into a folder Codex
doesn't read. Every other Codex session on the same home, whether an unselected seat or a person's own session, is a
successful no-op for these hooks, even with no `node` on its PATH.
## How it works
1. **Install through Codex itself, only for a selected seat.** With `runtime.codex.hooks_enabled` on, the install runs only
when a Codex seat's resolved plugin selection includes `openrig-core`:
- **at that seat's launch, when it starts a conversation:** the same check that sets the seat's
`OPENRIG_SEAT_PROJECT_DIR`. A resume (a launch with a resume token, or building the in-place restart's resume command)
never installs;
- **at boot:** after the plugin is vendored (so it installs the version this daemon ships), when some Codex seat's saved
selection includes it.
Launching an unselected Codex seat never installs it. The install:
- writes a local marketplace manifest beside its vendored plugin (`<plugins>/.agents/plugins/marketplace.json`);
- runs `codex plugin marketplace add <plugins>`, then `codex plugin add openrig-core@openrig`, under its own
`CODEX_HOME`.
It writes nothing into Codex's plugin cache, and no hook trust or hook state.
2. **The person's choice comes first.** Before any install or reinstall, it reads the Codex config files it can see: the
system `/etc/codex/config.toml`, the user `$CODEX_HOME/config.toml`, and the legacy `/etc/codex/managed_config.toml`.
- **An explicit `[plugins."openrig-core@openrig"] enabled = false`:** stays disabled.
- **A `[plugins._default] enabled = false` with no explicit value for the plugin:** stays disabled. Codex applies that
default, and an install would replace it with an explicit true.
- **An explicit `enabled = true`:** allows it.
- **A file that can't be read or parsed:** installs nothing and reports why.
- **Upgrades follow the same rule.** A new plugin version is reinstalled only while the plugin isn't disabled.
3. **A guard before any interpreter.** Every entry in `hooks/codex.json` starts with
`test -n "$OPENRIG_SEAT_PROJECT_DIR" && test -f "$OPENRIG_NODE" && test -x "$OPENRIG_NODE" || exit 0`, then
`exec "$OPENRIG_NODE" …`. OpenRig sets both variables only for a Codex seat that selected the plugin (on fresh launch,
handover and successor), with `OPENRIG_NODE` the daemon's absolute Node. Codex runs hooks through the login shell, and
a missing command there would be a failed hook; the guard exits 0 first.
4. **Activity is reported, possibly twice, never zero times.** On Codex the plugin's relay always reports. Whether Codex
also runs the daemon's own managed relay for an event depends on hook state merged from every user layer (a selected
profile included) and session flags, and on the handler as written, none of which the plugin can see. So on a selected
Codex seat with both routes active, each activity event is reported twice. The daemon records both; nothing is lost.
## Changed results
- **Codex seats that selected `openrig-core` get its refocus and activity hooks,** once Codex's exact-hook review admits
them. Review is still required for each hook.
- **The daemon's Codex `config.toml` gains** `[marketplaces.openrig]` and `[plugins."openrig-core@openrig"] enabled = true`.
Codex's own install writes them.
- **The plugin's skills become available to every Codex session on that home,** including a person's own sessions.
- **The plugin manifests move to 0.1.16,** so vendoring replaces an older installed copy.
- **Launch can take longer the first time.** When the installed plugin isn't current, the install runs Codex's own CLI
inside the launch (or boot) path: up to two commands, each with a 120 s limit. It is skipped once the vendored version
is installed.
- **The person's user config is written, through Codex.** Codex's install writes the person's `$CODEX_HOME/config.toml`
(the marketplace entry and `enabled = true` for the plugin). OpenRig only writes its own marketplace manifest beside the
vendored plugin.
- **The plugin's Codex relay entries now pass `--from-plugin`.**
## Known limits
- **A Codex started from inside a selected seat's shell** inherits the seat's variables and acts as that seat. This
change doesn't add launch isolation.
- **Config OpenRig can't see:** profile, project, session-flag, MDM and cloud-managed config. A disable set only there
isn't seen before install. Codex itself still honours it when loading the plugin.
- **Duplicate activity:** on a selected Codex seat with the daemon's managed relay active, each activity event
(SessionStart, UserPromptSubmit, Stop, PermissionRequest) is reported twice, by the managed relay and by the plugin. It
is never lost, and it doesn't count twice toward promoting the hook rung.
- **Promotion is counted per route:** it's slower when the two routes cover different stretches, never faster.
- **Tested Codex version:** the behaviour was read from Codex's public source at `rust-v0.163.0`. Other versions may differ.
Refs #1341
A pinned agent image keeps its protection from deletion when another version shares its image name.
The evidence guard keyed protection by image name, so pinning one version could be overridden by, or leak onto, another version with the same name. It now evaluates protection per version again; the name-based reference and lineage rules are unchanged. Tests cover the guard directly and the library route: deleting the pinned version is refused with "pinned", and the other version still deletes.
`rig seat restart --keep-conversation` for a Codex seat now refuses before stopping or typing anything in one case: argv proves the stored conversation, and Codex's own retained logs, read with no detected incompleteness (every versioned database found, opened and queried), name only a different conversation. It returns `resume_binding_unverified` with "Conflicting retained native-thread evidence…".
- **Everything else keeps today's `ok`,** with an additive `currentConversation` note: "no other conversation logged", or "not established: …" for an unreadable database, incomplete discovery, ambiguous evidence, nothing logged, or no CLI thread. Unknown never refuses.
- **How:** an optional `discovery` out-parameter on `resolveCodexDbPaths`, an optional `readability` one on `readCodexThreadIdFromLogs`, and one new method, `resolveWithReadability`. Callers that don't pass them are unchanged.
- **Not added:** new timing, a fresh fallback, automatic repair or an identity gate.
- **Limits:** the read isn't an atomic snapshot; a switch Codex never logged can't be seen; the start-second gate applies.
A caller can reuse one `AbortSignal` across completed requests without accumulating `abort` listeners. The request signal still follows the caller's cancellation after response headers arrive, which long-lived streams such as workflow follow need.
This replaces the manual listener in `fetchWithTimeout` with `AbortSignal.any`, and adds tests: the caller's listener is released after each settled request, a caller abort still reaches a pending request and a stream after its headers, and a non-Error abort reason is normalized.
A send to a seat that's being compacted or restored by OpenRig goes through as it always has, and the sender is now told, so they know to check the reply and follow up.
- **Delivery is unchanged.** `send()` wraps the existing path. With nothing recorded it returns the same result object; failures keep their exact results and statuses.
- **The notice:** when the compaction enforcer has recorded a managed compaction or restore for the seat, the result gains `managedCompaction { stage, observed, notice }`. The stage is preparing, compacting, compacted or restoring; `observed` says whether it was seen when the send started or when it finished. The notice never says the restore has finished.
- **The CLI:** `rig send`, a fan-out send (`--to`, `--pod`, `--rig`) and `rig broadcast` print it as a `Notice:` line under that recipient. Exit codes and `--json` are unchanged.
- **The source:** two synchronous reads of the enforcer's existing in-memory records, each guarded, so the send path gains no I/O or new failure.
- **Not printed yet:** a cross-host single-seat `rig send` over HTTP (its JSON keeps the field), `rig walk`'s per-piece output, and `rig send --verify`'s automatic Enter retry.
When `rig queue whoami` can't settle a seat's single current work node but the seat holds in-progress rows, it now says so and names every candidate, instead of showing only a count.
- **The daemon** adds `currentWorkChoice`: an instruction to choose, plus each candidate with its `projects`, `mission`, `slice`, `workNodePath` (or the reason it didn't resolve) and its source `qitemId`. In-progress rows that name no work go under `unknown`. Nothing is selected for the agent.
- **The CLI's plain output** ends with one line per candidate.
- **Unchanged:** `currentWork`, `currentWorkBasis` (the refocus hook reads both) and `--work-candidates`. The field is absent when current work is settled or nothing is in progress.
- **Reuse:** it's built on the existing work-candidates projection. Candidates gain `projects`, and the dedupe key includes them, so two projects' same-named rows stay two candidates.
Today's resolver uses one configured root; the tests feed in the two cross-project ambiguity cases a project-aware resolver would return, and check that both candidates are named.
Refs #1526
The TUI shows each seat's reasoning effort, read-only.
- **The table and detail view** show the effort a managed launch sends, the seat's explicit choice or else the authored value, and where it came from: `seat_choice`, `authored`, or `runtime_default` when no configured level is known. It never guesses what the model ran.
- **The detail page** reads `GET /api/seat/effort/:seatRef` once per refresh. The table makes no per-seat requests.
- **Failure handling:** a daemon's JSON refusal is shown as `report refused <code>` without a read warning; an older daemon's bare 404 reads as unknown with no warning; timeouts, transport errors, malformed refusals and other HTTP errors keep their named error. Other reads through the shared client are unchanged.
- **No write route,** default, permission change or restart is added.
A Slack message the person sent before an agent's question appeared could resolve that question as its answer. Now only a reply written after the question's current post answers it.
- **Early or extra messages** still reach the agent as ordinary messages, correlated to the question, so the agent sees them as context. Nothing stitches messages into one answer.
- **The posted question says so:** "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation."
- **A re-park** asks a new question, so a reply to the older one doesn't answer it.
- **Multipart questions:** when the primary part has posted but the final receipt isn't stored yet, the primary post's real Slack timestamp is used.
- **Unknown timing:** when a delivered, correlated current question has no comparable timestamp (for example, a reconciled post with no Slack ts), a later reply still resumes it, with a separate note to the agent that its ordering couldn't be confirmed and a short notice in the person's thread. A reply known to be earlier never resumes it. This is a compatibility trade-off: an actually early reply can resume such a question when its order truly can't be recovered. It isn't proof of timing, of reading, or of approval.
- **Never from weaker evidence:** an older or unkeyed receipt, an unmapped thread or an unreadable store resolves nothing; the park stays with its owner. A failed notice is reported as a failure and retried alone.
- **Unchanged:** direct requests, buttons and structured answers, reactions, and the already-resolved replay.
The tests drive the queue, SQLite, Mission Control and the Slack wire with modelled timestamps. No live Slack account was used.
Refs #822
`rig up --env` stops a seat whose one-off value couldn't be removed from its session after the seat started. In a several-seat launch the rest of the team keeps running, so the old advice, "Run it again", would collide with the team that now exists.
- **The new guidance** names the stopped seat's own `rig launch <rig-id> <seat>` command (a pod member's qualified name for pod specs) and says it doesn't reapply the unsaved `--env` values. If those values are needed, `rig down <rig-id> --delete` stops and removes the team this launch just created, and the original `rig up` command can then be run again with `--env`. Nothing is stopped or deleted automatically.
- **Covered:** flat specs, v1 bundles and pod specs, which every built-in team uses. Pod-spec human output now also names the stopped seat, its setting and the reason.
- **One exit code changes:** a pod-spec cleanup partial now exits 1 instead of 0 with `--json` and with `--host`, matching its human output, flat specs and v1 bundles. Single-seat and all-failed launches still exit 2, and lifecycle and status are unchanged.
- **Unchanged:** an all-failed launch still says "Run it again.", an unconfirmed removal still says to check and remove the surviving session first, and NodeLauncher and direct launch are untouched.
The tests run through the real bootstrap, SQLite, the up route and the CLI with an inert tmux boundary. Native provider behaviour wasn't run.
Refs #1511
On a Mac with more than one user account, a second user's `rig` commands can reach the first user's daemon on its default port. This adds an optional check, off by default.
- **The setting:** `transport.local_user_guard` (default `false`). When it's on, the CLI's common client checks a local daemon before talking to it.
- **The evidence:** the daemon's `/healthz` now reports its own effective UID as `euid`, beside `pid`. The check reads it twice and corroborates it with `ps -o uid=,lstart= -p <pid>` twice (fixed path, no shell, bounded to 1 s).
- **When it refuses:** only when every read agrees on a UID different from the caller's effective UID. The error names the reported PID and UIDs, and the way out: `rig daemon start --port <unused-port>`, or a deliberate `OPENRIG_URL`.
- **When it permits:** an older daemon with no `euid`, malformed, changing or disagreeing reports, `ps` failing or timing out, a non-loopback URL, or a non-Mac platform all read as unknown and let the call through. A same-user daemon never refuses.
- **What it isn't:** it trusts the daemon's report and corroborates that process; it doesn't prove who owns the socket. It's a partial check against accidental routing, not authentication or user isolation, and identity can change between the check and the connection. Running `sudo rig …` against a user-owned daemon refuses when the guard is on.
With the guard off, kernel readiness resolves the setting once before its wait and keeps the same polling and final status request as before. Each request still reads the setting once. `/healthz` gains the additive `euid` field whether the guard is on or off.
Hosted Mac checks observed an owned listener reporting its UID and an unprivileged `ps` read of another account's process. A live two-account daemon session wasn't run.
`rig up --env KEY=VALUE` completed flat-spec and v1-bundle launches without passing the override to their panes. It now goes through the shared launcher, so every first pane gets it. The value isn't saved on the rig or node, and it's removed from tmux session state before later panes start.
Removing it can fail after the first pane has started, and the result now says so:
- **What happens:** the launcher tries to kill the new session. A confirmed kill stops that launch. A failed or throwing kill leaves an unbound session that may still hold the value. Neither is an undone launch or a refusal before any effect.
- **What's reported:** an all-failed flat or v1 launch keeps the value-free guidance naming its session and key. A mixed result reads partial, with that guidance in human output and a non-success exit for human and JSON output, local and remote.
Only this cleanup failure changes how legacy results are aggregated. Pod/v2 launches, plans, launches without an override, restoration and unrelated launch failures are unchanged.
The tests run through the real bootstrap, SQLite, the up route and the CLI, with an inert tmux boundary. Native provider behaviour wasn't run.
Codex resume-token parsing used a narrow option set and skipped only `--add-dir` after `resume`, so a model or directory value could be read as the resumed conversation.
- One shared set of value-taking Codex options now applies before and after `resume` and `fork`: `-a`/`--ask-for-approval`, `-c`/`--config`, `-m`/`--model`, `-p`/`--profile`, `-s`/`--sandbox`, `-C`/`--cd`, `--add-dir`, `--enable`/`--disable`, `--local-provider`, `--remote` and `--remote-auth-token-env`. `--` ends the options, and everything after it is the fresh launch's prompt.
- `-i`/`--image` takes one or more values, up to the next option or `--`. An image value never proves a conversation.
- A positional used with a real `--last` is the prompt and names no conversation. A `--last` after the second positional is prompt text, so `codex resume T explain --last selection` still proves `T`, while `codex resume T --last` proves nothing.
- Mismatch explanations no longer show the values passed to model options.
Known limit: process text loses argv boundaries, so an explicit resume can't be told apart from every multi-word prompt used with a real `--last`. In that shape the leading token is still read as the conversation, as it was before this change, so exact conversation attribution keeps that ambiguity. Only a token equal to the seat's own recorded conversation is ever accepted.
The tests drive the parser and its consumers with flattened `ps` rows. Codex's native CLI wasn't run.
Onboarding docs: let your coding agent set OpenRig up, and say plainly what installing changes.
- **New `docs/reference/quickstart.md`:** the human getting-started page, with a short glossary of the first words a person meets.
- **`docs/reference/getting-started.md`:** a new title and opening for the agent doing the setup; the rest of the page, and every inbound link and `rig context get` lookup into it, is unchanged.
- **README:** the skill-first install block opens "Install and first run", followed by "Install it yourself"; a short box says what installing and starting change on the machine; the Getting started links point to the new page, and Key Concepts links to the glossary.
- **Facts kept current:** the example tags and version sample now match the v0.6.12 install pin.
- **`scripts/check-first-run-references.mjs`:** the first-run references check now also covers the new page.
Docs only; no product behaviour changes.
Repair a missing tmux pane binding without adopting the session.
- **The problem:** a seat launched by an older OpenRig can have a tmux binding with no pane recorded. Current OpenRig then refuses every send to it ("Managed pane identity unavailable or changed; no input written."). The default `rig reconcile-session` can fix sending, but it adopts the seat into a new session and occupant generation and leaves the new session without a resume token.
- **What changes:** a new opt-in `rig reconcile-session <seat> --repair-missing-pane --no-launch`, backed by its own authenticated endpoint. It acts only on a running launched session whose binding has no pane. It observes the session's sole pane and its PID twice, then, inside the existing delivery guard's checks (no active human input, no pending work, protection off), makes one conditional update of `tmux_pane` and `updated_at`. Session, token, tenure, restore policy, protection and capture records are unchanged, and the result reports the same session id with continuity unverified.
- **Refused, with nothing written:** multiple panes or windows, an unobservable pane, a binding that already has a pane, a change between observations, active work or protection. An older daemon without the endpoint gets "not supported; nothing was changed", and the CLI never falls back to adoption.
- **Unchanged:** the default reconcile and ordinary sends.
The repair records no event; `updated_at` and the command's result are its only trace. A delivery held from before the repair, with the missing pane in its snapshot, still refuses afterwards. Tests use the real service, SQLite, guard and adapter code with inert process effects; no native tmux or provider run.
Refocus resolves notes without starting a CLI process for every ancestor.
- **What changes:** the refocusing trace now finds each work ancestor's notes file itself: a readable regular `NOTES.md` first, then `MISSION_NOTES.md`, the same rule and the same path string as the CLI and daemon resolvers. Before, it ran `rig scope resolve-notes` once per ancestor, and on a loaded machine those starts could eat the trace's time budget and end in a trace gap.
- **Unchanged:** symlinks, the readable fallback, custom roots, the gap reported when a resolved file becomes unreadable before it's read, the hook's budgets and settings, and the context content produced. There's no new route, cache, daemon call or setting.
- **Delivery:** both bundled openrig-core manifests move from 0.1.14 to 0.1.15, because an installed plugin is replaced only by a strictly newer version.
Tests compare the script's resolver with both TypeScript resolvers across both names, legacy only, neither, a directory, symlinks and an unreadable file (run under a non-root UID). A delayed-CLI test fails on the old script and passes now. The hook's failure retention, check-mode exit and health receipt are still covered, now through a real outside-root trace failure. Removing these process starts doesn't prove every loaded-machine trace now fits its budget.
`rig ask --wake` reports an unsupported runtime instead of silently attempting a Claude wake.
- **What changes:** seat resolution returns a resume token only for Claude (including the `claude-code` alias) and Codex. A Pi, custom or empty runtime gets a clear refusal and no token. The CLI also checks a raw-id `--runtime` value and the daemon's answer before it calls the wake runner.
- **Seats with no node runtime recorded:** they still resolve when the session itself positively recorded a Claude or Codex id (`claude_id`, `claude_name` or `codex_id`). There's no blanket "unset means Claude" fallback, so a Pi or unknown seat stays refused.
- **Unchanged:** the default Claude wake, the Codex wake, an explicit runtime's precedence over recorded metadata, generation indexing, timeouts and transcript search.
Tests drive the real claim, hook and resolver query path, failing first: unsupported runtimes make no wake-runner call and return no token; recorded Claude and Codex sessions on a node with no runtime still resolve; supported aliases and generation behaviour are preserved. Providers were test doubles; no native wake ran. A client with this fix can't detect an older daemon that already labelled a Pi token as Claude, so seat resolution needs the corrected daemon too.
Manual managed compaction no longer gives up the moment its preparation stops at an ordinary approval prompt.
- **What changes:** while the seat prepares its restore map, a manual compaction now waits through `target_needs_input`, re-observing within the same attempt and deadline. It never answers the approval, types into an attention, busy or unknown screen, holds the input lease while waiting, resends the preparation, or revives a stopped attempt. When the seat becomes idle with its map ready, the one compact goes as before.
- **The budget:** manual preparation now has 180 seconds (was 120), and `rig compact`'s request timeout is 240 seconds (was 180), with help and docs. A caller can therefore wait up to 60 seconds longer than before; this isn't a promise that every preparation finishes. An explicit `--skip-map` request uses the same manual budget; it only drops the map requirement.
- **Unchanged:** generic sends, the automatic preparation budget, map identity, cancellation, occupant and policy checks, the final idle and before-write checks, and uncertain-delivery handling (still no retry).
Tests drive the existing enforcer and a real transport fixture, failing first: attention then idle gives one preparation and one compact; unresolved attention at the deadline gives none; a map between 120 and 180 seconds now completes; expiry stays final; and cancellation, occupant and policy changes, stale markers and uncertain delivery behave as before. These don't establish native preparation, compaction or restore behaviour.
ROADMAP drops three items that are finished, each with its issue closed as completed:
- Codex seats that can reach the local daemon by default (#275).
- Custom Codex providers such as Bedrock (#194).
- A configurable readiness window for fresh launches (#182).
Deletions only; no new roadmap text.
A Codex account's allowance readings now survive a daemon restart and a login switch, shown as history rather than as current availability.
- **Storage (migration 117):** one new table, `provider_account_readings`, keyed only by the provider's existing identity key (account id or workspace routing, plus the bucket). The writer stores selected normalized fields only: identity labels, each window's name, duration, used percentage and reset, the limit evidence and the observation time. No raw provider answer, auth file or profile content is stored. Retention is bounded per key: the latest reading, the latest per window, and the latest of each of the last 20 exhaustion episodes. A row whose windows can't be read is skipped on its own and never pruned.
- **What rows show after a restart:** each allowance's last reading comes back as history, labelled "kept from before the daemon restarted at <time>; this login's sign-in hasn't been read since", with the state unknown. It becomes current only when that account is read again; a different account's read, a timeout or a missing sign-in leaves the label in place. A superseded live row keeps "not the latest read". A passed reset shows an expectation, never a reading, and retained windows name the refresh command.
- **Cross-host views** validate the five new row fields; a malformed value makes only that host's usage rows unavailable, with a shape reason, and healthy hosts and hosts without the fields are still served.
- **Projection** holds each allowance's last exhaustion in memory instead of querying the store per projection.
- **The CLI and TUI** print history and sign-in on one row, including a Claude row with no identity yet.
`usage_samples` is unchanged. Native Codex logins, a real daemon restart and multi-machine fan-out weren't run; the tests drive the store, the projection and the cross-host reader with synthetic readings, and an independent QA pass covered restart, reopen and malformed-row cases.
Development main now carries the published 0.6.12's versions and release text.
- **Versions:** root, `packages/cli`, `packages/daemon` and `packages/ui` read 0.6.12, and the lockfile matches; `packages/tui` stays 0.1.0.
- **Release text:** the 0.6.12 CHANGELOG section and whats-new entry as published, and `docs/releases/v0.6.12.md`, the published release notes byte for byte.
- **Capability map:** 0.6.12's additions (for example `rig seat set-effort` and `rig usage cost`) alongside main's later entries (`rig seat set-cwd`, the adopted-seat restart note, Pi effort and `rig usage top`).
- **Installer pins:** the README and getting-started now name `v0.6.12`, keeping the standard-user Mac Node route.
The release's two text and version commits are cherry-picked with `-x`; this isn't a merge of `release/0.6.12`, whose source changes main already has whole.
Daemon CI no longer starts failing when the calendar passes the usage fixture's fixed reset date. The shared loopback-host harness paired a fixed reset (2026-10-20T00:00Z) with the real clock, so after that moment a slow Codex refresh read `level: unknown` instead of `nearing` and `package-tests (daemon)` failed on every branch. The harness now defaults to a fixed fixture clock before its reset; explicit `now` overrides and real slow-request timers are preserved. The same date audit replaces the chat-history test's calendar cutoffs with times relative to its real clock.
Test-only: no product source, dependency or version changes. Verified by a hosted run that reproduced the original failure with the old harness and passed 60 daemon, 24 CLI and 12 TUI tests with the repaired one, covering every identified consumer of the shared harness. This is a scoped audit of date-sensitive fixtures, not a claim that every test is now deterministic.
A fresh in-place handover of a Claude seat keeps the retiring occupant's scrollback, so the readiness check could match the old Claude's text and report the successor ready before it started, and the handover packet was typed early. Now a ready result read from that history is qualified with the new launch's current frame, only for the exact launch that asked for it.
- **What waits now:** when the current frame isn't ready, for example a shell with the launch line, the packet waits for the successor. That includes shells whose prompt starts with `❯` when the process list was read and shows no Claude under the pane.
- **What still admits, as today:** a headerless frame that lacks only process proof (bypass, plan or default permission modes), an unreadable process list, an observed but unproven Claude process, unavailable pane identity, and an unavailable current screen. These leave the frame ambiguous and keep today's admission.
- **What refuses:** a positively different session token or runtime under the pane stays a conflict. An unidentified Claude (no token, or an argv the parser can't read) is unknown, not a conflict.
- **Unchanged:** visible gates are still checked first, an old not-ready result is never promoted, and resume and launch callers keep their strict identity rule.
Not covered: once the launched Claude is running, the existing process-proved readiness accepts the same loose `❯` matcher, as before. Native renderer timing and packet consumption weren't run; the tests drive the real handover service, launcher and adapter with modelled process and screen observations, red first against the previous head.
Refs #1544
Two fixes from a first-user run on a Mac with fresh accounts.
- **Node.js for a standard Mac user:** someone who isn't an administrator, or whose Homebrew belongs to another account, can install Node.js in their own home folder with nvm. They run `touch ~/.zshrc` first, because a new Mac account has none and nvm's installer only adds itself to a profile file that already exists. Then they run nvm's install command, open a new terminal, and run `nvm install 22`. No administrator password is needed, and `npm install -g` then installs into the nvm folder. This is in `docs/reference/getting-started.md` and the README. Nothing is claimed about Node.js's own installer for a standard user.
- **What the registry checks decide:** `docs/reference/publishing-a-rig-bundle.md` now says that a submission with a taken name is still received, that a team sharing a listed team's `rig.yaml` name (such as an unchanged copy) passes with a maintainer notice, and that listing is the maintainer's review decision, not the checks'. Registry policy is unchanged.
Text only. The registry wording was read against `tools/registry-check.mjs` and `tools/ci/bundle-check.mjs` at the registry's main.
## What changes
- **Channels this machine owns:**
- **Which ones:** the default channel and the channel map's channels. A channel the hub relays to another machine is left to that machine (the owner), so it isn't delivered twice.
- **Coverage start:** a channel newly recovered starts at adoption, and older history reads as unknown. A landing or seen record isn't scanned coverage, so it never sets a floor that could skip an earlier hole. The default channel of the machine holding the Socket Mode connection keeps its existing seed.
- **Replies in tracked threads:**
- **Which threads:** threads whose root was posted, or which closed, within the last seven days. A root's age is its Slack timestamp, not the thread map's `opened_at`, which a map rebuild resets; so a rebuild doesn't make an old thread eligible again. An old thread that closed recently is scanned from its root, so this isn't a seven-day history cap.
- **The cost:** missed replies in older open threads aren't caught up. Live delivery is unchanged.
- **How they're scanned:** with `conversations.replies`, starting at the root, oldest first, 15 per call (Slack's cap for apps distributed outside the Marketplace).
- **The checkpoint:** it moves past a reply once it landed, was already delivered, was kept as a dead letter, was refused because its sender isn't admitted, or was skipped as a kind inbound doesn't take. A restart resumes at the last page saved. It's kept in its own file, so a reader of channel checkpoints never meets it.
- **A thread Slack can't find** (`thread_not_found`) stays incomplete, with no further call, until its mapping changes or the daemon restarts or rewires the connector. That's not taken as proof of deletion, and transient errors still retry.
- **Late answers to decisions:**
- **How it lands:** a recovered thread reply lands as an ordinary reply row in its thread, marked late with its original time. So does a broadcast reply recovered in a newly covered channel: a mapped channel, or a relayed machine's default channel.
- **What it never does:** answer the decision, even one that's still open, and even after a dead-letter retry. The row says it wasn't applied, and the seat reads it and decides.
- **Unchanged:** broadcast replies recovered in the Socket Mode machine's default channel, and every live reply, behave as before. A live envelope can't claim the mark.
- **One budget per pass:**
- **Limits:** four calls, 100 entries and 15 seconds per pass, as before.
- **Sharing:** channels and threads take turns call by call, each kind in rotation. A kind with nothing to scan gives its turns to the other.
- **Rate limits:** a `Retry-After` holds back only the method Slack limited. Every backoff saves whether it came from a 429 (`rateLimited: true` or `false`), and the method hold is rebuilt from the saved deadlines before any source runs, so it survives a restart. A future deadline with no marker, as an older daemon saved every backoff, is treated as a possible 429 and holds its method until that deadline, never extended. Channel history and thread replies stay independent.
- **Machines without their own connection** (a shared app's other machines) recover a channel only when it's one they own and the hub has relayed an event for it. Its coverage starts at that event's local receipt, not the next timer pass. The adoption is saved per channel, so a restart needs no new event, and saved coverage wins over a new floor. A saved adoption never widens what the machine currently owns. A machine nothing is relayed to never reads its channels' history.
- **`rig slack status`:**
- **The lines:** one per additional channel, with a relayed machine's not-yet-adopted channels shown as waiting. A thread line gives the tracked, scanned and waiting counts, how far behind the oldest is, and the reasons for any thread that's backing off or incomplete.
- **Counts:** accepted and dead-lettered counts cover the connector's lifetime, including threads that have since left the window.
- **Docs:** `docs/reference/slack-app-setup.md` describes the new scope and its limits.
## Not changed
- **Today's behavior:** the configured channel, its broadcast replies and live inbound behave as before. Relay authentication is unchanged.
- **No new scope or setting:** `channels:history` already covers both methods, and there's no new setting, prompt or polling loop.
- **Out of scope:** threads nobody tracks, older open threads, threads closed more than seven days ago, and history Slack no longer returns aren't recovered.
- **Timing:** with many tracked threads, each one is scanned every few passes, and status says how far behind the oldest is.
## Compatibility
Relay adoption (`relayAdoptedAt`) and the rate-limit marker (`rateLimited`) are optional fields in the existing channel coverage file; there's no new store. An older daemon reads the file, accepts the fields and keeps them when it saves, because its saves spread the existing record. If one is lost anyway, that channel waits for a fresh relayed event and keeps its coverage. Thread coverage (`slack-thread-coverage.json`) is new here and isn't pruned yet.
**A downgrade seam, accepted as a cost:** after a downgrade, an older daemon's spread save keeps a `rateLimited: false` this version wrote. If that older daemon then gets a real 429, it replaces `nextRetryAt` but leaves the stale `false`. After upgrading again inside that `Retry-After` window, recovery trusts the `false` and doesn't restore the method-wide history hold. The affected channel still waits for its own deadline, but other channels can call `conversations.history` inside it. In an uninterrupted pass with a single recovery owner, the first of those calls that receives a 429 restores the shared hold: the steps run one at a time and share one method backoff, and a definite live 429 sets that hold before another source takes its step. That's not a promise of at most one extra call in every case; multiple recovery owners, repeated restarts, lost checkpoint writes or a response not observed as a 429 can each allow more. The pass budget (four calls, 100 entries, 15 seconds) is unchanged. This is a compatibility judgment from the source, with no new product guarantee and no measurement against Slack.
## Changed results and costs
1. **The hub's mapped channels** are recovered from their adoption.
2. **A relayed machine** recovers each configured channel the hub has relayed to it, from that channel's first relayed event's receipt, and across restarts. Main didn't recover on relayed machines.
3. **Thread replies** in threads whose Slack root, or whose close, is within seven days are recovered as late rows that never answer. This selects threads; it isn't a seven-day history cap, so a recently closed old root catches up older replies. **Cost:** missed replies in older open threads aren't caught up. Live delivery is unchanged.
4. **Recovered broadcast replies** in newly covered channels (the hub's mapped channels and every relayed-machine channel) land late and never answer. The Socket Mode machine's default channel and live replies are unchanged.
5. **The default channel's share:** when tracked threads need calls, channels get every other call and rotate among themselves. **Cost:** a default-channel backlog can drain at up to about half main's rate when both kinds are busy. That's an upper share, not a fixed slowdown, and there's no latency or global ordering promise.
6. **A history 429** on any channel holds `conversations.history` for every channel until its deadline, across a restart; a replies 429 does the same for threads. On main a 429 held only the default channel's own retry. **Cost:** an older daemon's unmarked deadline, or a `true` marker it kept after a downgrade, can represent a non-429 failure, so re-upgrading before that deadline can delay `conversations.history` for the recovery owner's channels until that saved deadline. The bound is that particular saved deadline, including a `Retry-After` sent with a non-429 response; there's no fixed maximum, and it can happen again across repeated downgrade cycles. It makes no extra API calls, and thread replies keep their own separate hold, so it isn't a freeze on all Slack calls. A deadline marked `false` holds only its own source.
7. **Status:** per-channel lines, a thread line, connector-lifetime totals including threads that left the window (reset when the connector restarts or rewires), `waiting (no-relayed-event-yet)` per channel on a relayed machine, and changed limit text.
8. **At upgrade:** threads inside the window are scanned from their root once. Replies lost in the last seven days or so (longer for a recently closed old root) land as late rows; ones that already landed are deduplicated by `channel:ts`.
Refs #739.
An OMP seat whose assistant turn ended with a typed transient provider failure can now take an ordinary retry message after its terminal `agent_end`. Before, the runner kept `runtime_error` as `needs_input`, so the send guard refused the retry as though it could answer an approval prompt.
- **What changes:** when the failure carries a numeric `errorStatus` of 408, 429, 500, 502, 503, 504 or 529 (or OMP's matching statusless retry-exhaustion summary follows it), the attention clears at the terminal `agent_end`, the seat reads idle, and an ordinary send is accepted. The failure stays in the pane and in the activity history. Before the terminal `agent_end`, and at a non-terminal one while OMP retries, nothing changes.
- **What still needs attention:** failures without a typed status (including the prose "529 overloaded"), any other status, denied approvals, rejected input, an earlier untyped or persistent failure in the same turn, retry summaries that don't match the attempt and error, and Pi seats. A failure keeps attention by its status, not by its cause: an authentication or configuration failure whose status is one of the seven releases like any other.
- **Two consequences:** during a sustained provider outage the seat no longer stays in `needs_input` after each exhausted retry, so it stops showing as needing a person; and with OMP retries turned off, a single typed 429 reads idle. No automatic retry is added, and the send guard and wake policies are unchanged.
Checked against OMP v18.2.11 (`e4151593`) source for `errorStatus` and the exhaustion summary; native OMP and provider behaviour weren't run. Tests drive the runner, the activity store and the send guard together, with each status and correlation case.
Thanks to @1solomonwakhungu.
Implements the proposal agreed in discussion #1428. Before, a Pi seat's `effort` was stored but never reached Pi, `rig seat set-effort` and `rig seat effort` refused Pi seats, and a Pi seat with `effort` set ran at Pi's default. After, already-authored efforts take effect at each Pi seat's next managed continuation.
- **Launch and resume:** a Pi seat's resolved effort (the seat's own choice, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh, successor, handover and restore. Only a value of `set-effort`'s shape is sent; with no value or a value that fails the shape, no flag is sent and Pi uses its own default level. The model string never changes, and OMP command lines are unchanged.
- **`rig seat set-effort` and `--clear`** work on Pi seats. The choice applies at the seat's next managed continuation, and nothing restarts. An unfamiliar level gets a warning, not a refusal.
- **`rig seat effort`** reports a Pi seat four ways: requested, saved, sent at launch (from the identity-verified runner's own command line) and observed (from the level Pi 0.99.0 or later records on this launch's latest successful assistant turn). It reports NOT VERIFIED when it can't tie a turn to this launch.
- **Spec advisory:** an unfamiliar Pi `effort` says the managed launch leaves it out when the value fails the shape check, and that it's sent anyway otherwise.
- **The Pi runner** decides control flags as it parses, so a value that follows a flag is never counted as a flag itself: an OMP `--model` value such as `--approve` now parses, where it was rejected before.
The Pi identity check and the model report are unchanged. Tests cover the builders, the runner's parser, set-effort, the effort report and the spec advisory; the author also checked the flags against real Pi 1.3.0.
Thanks to @smota.
In a managed Claude seat (one started with a native permission mode), Claude's external-editor action (Ctrl+G) opened VS Code instead of the person's `$EDITOR`, and Ctrl+V image paste failed, because the managed launch clears the environment and forwarded only declared names, the terminal names and the locale.
- The launch now also forwards `EDITOR`, `VISUAL`, `DISPLAY` and `XAUTHORITY` by name, expanded in the pane shell like the existing terminal list, so an absent or empty variable stays absent.
- `WAYLAND_DISPLAY` is forwarded as an absolute path: an absolute value is kept, a relative one is joined to the pane's `XDG_RUNTIME_DIR`, and a relative one with no `XDG_RUNTIME_DIR` is left out.
- `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` are not forwarded, so the seat isn't handed the session bus by name. `SSH_AUTH_SOCK`, `TMUX` and `TMUX_PANE` are unchanged.
- Every path that launches or relaunches a managed Claude seat builds its command in one place, so the change covers launch, fresh launch, restore, handover, resume, fork and `rig seat set-permissions`. It takes effect at the seat's next launch.
A `git commit` without `-m` inside such a seat now opens the person's editor, as unmanaged and Codex seats already do. Tests run the generated command in a shell.
Thanks to @AdamWhitehurst.
A Claude seat sitting at Claude Code 2.1.303's MCP-server approval menu no longer restores as resumed and ready, and an ordinary `rig send` no longer pastes into that dialog. One strict predicate recognizes a complete current MCP menu: its heading, instruction and full warning, at least one checked or unchecked checkbox row (the cursor on any row, an optional "Enable selected" action) and the old or new end footer as the last line. Anything after the footer, such as a later composer or a draft, makes the menu history.
- The native resume assessment applies it at its existing MCP gate, before the active-runtime and verified-identity shortcuts; the older substring matcher stays there unchanged, including its known false positive on an old menu left in history.
- The pane activity classifier uses the same predicate as selection-prompt evidence, ahead of its unchanged selection, permission and idle handling.
Changed results: the two complete current menus (old and 2.1.303 wording) now refuse a no-hook send (409, no input) and read as attention and needs-input in structural activity, the startup permission reader and the full inventory; the 2.1.303 menu now reads `mcp_gate` and not ready. Genuine composers, quoted menus, MCP prose, clipped menus and ordinary permission prompts keep their results, and no working non-dialog frame is newly refused. A fresh running hook still sends into a menu, as before.
Tests drive nine fixed frames through the real assessment, `checkReady`, the startup reader's conversion, the classifier and the loopback send route with recorded paste and key effects, plus cursor, checked-state, single-server, CRLF and missing-part variants. The captures are recreated from the reported 2.1.303 wording, not a native capture, and a full restore wasn't run.
Refs #1541.
Claude activity-relay and context-collector commands generated into a project's `.claude/settings.local.json` now use the daemon's quoted absolute Node executable when the managed-command parser recognizes it; other executable names keep today's bare `node` form and its PATH requirement. With a recognized pinned interpreter, a Claude session with an older PATH (started before Node was installed, or under a version manager that moved it) can run these hooks while that interpreter stays installed, instead of showing "node: command not found" on each reply.
- Each path and argument is POSIX-quoted individually. Existing bare-node commands refresh through normal projection and collector provisioning, and a repeat reconcile is byte-identical with no duplicate entries.
- Ownership checks, the plugin's settings-relay deduplication and restore-check matching accept the old and new generated forms together. A person's own wrappers, extra arguments and malformed settings are preserved.
- The plugin does not suppress its own event when the settings interpreter is missing, not executable or a directory, even when the relay script bytes match.
- The bundled core plugin advances from 0.1.13 to 0.1.14 in both manifests, so the existing version authority delivers the changed relay bytes; the CLI version is unchanged.
Limits: a removed or moved interpreter needs a refresh from a healthy runtime. The core plugin's static manifest invocations still call bare `node`, so on a seat whose projection loads the core plugin those Stop hook errors remain until the manifest follow-up, while the pinned settings relay still reports activity. No PATH prepend, shim, new setting, automatic restart or hidden runtime fallback is added. Native reload isn't proven here.
Tests exercise the actual writers (including a `node-22`-named interpreter), legacy and pinned refresh, idempotency, preservation of custom and malformed settings, matcher parity, no-Node-PATH effects and the missing-interpreter plugin fallback.
The refocus work packet's fields and lists are now bounded, and its default becomes a per-runtime switch that stays off on both runtimes until each is proven. Merging changes nothing a seat sees by default.
- With the packet on, each held or next row's summary, blocker and continuation is folded to one line and cut at 160 characters, with a full-row pointer whenever a field was cut or folded. Lists show at most 20 held and 10 next rows, with the totals (or a lower bound when the daemon itself cut the list) and the list command. Matched section bodies stop at 1,500 characters with a `--depth full` notice; full depth restores section bodies only. These are per-field and per-list bounds, not a total size cap.
- `WORK_PACKET_DEFAULT` is `{ claude: false, codex: false }`, and `OPENRIG_REFOCUS_WORK_PACKET=1/0` overrides it per seat. With the packet off, the hook's output is byte-identical to before.
- Strict current-work selection is unchanged. The parked-baton scenarios move with the bounds: packet on, a seeded fault, and packet off, plus the active control.
Activation per runtime waits for a native check that the packet arrives as input and changes the seat's next action against a packet-off control. Native hook delivery wasn't run here.
When `rig setup` (step 4 of the one-line installer) finishes without being ready, the installer now ends with `INSTALLED, SETUP NOT COMPLETE (exit N).` instead of `FAILED [4/4] … (exit 1)`, which read as "OpenRig didn't install" though the CLI was installed and the compatibility check ran. The summary gives the installed CLI path, the Node/SQLite check result (`passed`, or `SKIPPED (not checked; see above)`), a pointer to setup's own "Some steps need attention" list, with a Fix where setup gives one, or to the error that stopped it, and "Fix those, then run rig setup again; the CLI does not need reinstalling."
- Setup's exit status passes through exactly, and setup's own output stays above the summary.
- Steps 1-3 keep their `FAILED [n/4]` line, and a ready setup keeps today's closing line.
- It names no cause that setup's result doesn't establish: a provider check fails for a missing sign-in but also for a probe that timed out or errored, and the installer sees only setup's exit status, so the summary doesn't say "sign in".
- A skipped compatibility check is never reported as passed. Setup's readiness and exit behaviour, provider selection and every other exit status are unchanged, with no extra probe.
The README and getting-started are unchanged on purpose: they show the pinned release's installer, which still ends with `FAILED [4/4]`. They move together with the pin, at the first release tag that contains this script.
Tests: the wrapper tests cover setup exit 1, 40 and a piped run's 43, unchanged early failures, a skipped check and the ready ending; five setup-boundary cases cover sign-in only, a mixed failure, ready, an unselected provider and an uncertain probe.
A Codex pane that still shows an old numbered menu in its scrollback no longer refuses an ordinary message when it also shows a complete recognized empty normal composer and its immediately preceding running-status row, separated only by blank lines. Generic numbered-choice evidence above that boundary is treated as history, so those no-hook sends change from refusal to paste plus Enter.
- Drafts, queued input, inline content, missing, clipped or unrecognized frames, and an absent adjacent status keep the existing result.
- Literal permission evidence keeps its full scan and its priority. Current-region choices, permission hooks, fresh-running hooks, paste/Enter and hook freshness are unchanged, and runtime-less and Claude callers are untouched.
- The same frames also stop reading as a current prompt for the other readers of the pane classifier (the Codex permission-prompt reader and the inventory's full-capture fallback); a real permission prompt still takes the literal path.
The ownership rule comes from the parent renderer at Codex 0.160.0 (`a956835d`): stacked dialogs replace the normal status and composer, and inline previews follow the status. It's evidence for that revision and matching complete frames, not a guarantee about every Codex version or arbitrary captured text; an equal-byte synthetic alternative-intent label remains a text-information limit.
A 27-frame fixture ran through the production loopback send route, real SQLite, activity store, delivery guard and tmux input methods, under no hook, a fresh-running hook and a fresh-permission hook, on baseline and candidate: baseline had nine intended failures and the candidate passed all 81. Exactly three no-hook history outcomes change; the other 78 route outcomes and command records match. Native Codex wasn't run.
Refs #1533.
Four onboarding facts that had drifted, corrected in place. Text only, with no change to the onboarding path.
- The install script pin moves from `v0.6.8` to `v0.6.11`, the newest published release, in `README.md` and `docs/reference/getting-started.md`. Nothing moves these pins automatically.
- The `rigs` skill's footer drops "Written for OpenRig 0.6.7." and keeps the rule that `rig <command> --help` wins when it disagrees with the skill. Both copies stay byte-identical.
- The README and getting-started prerequisites now say one working Claude Code or Codex account is enough: `rig setup --providers` takes either, and the kernel operator adapts the shipped `starter` team to whichever the person has.
- The skill's disclosure of what starting OpenRig and a team writes now names Claude Code workspace trust in `~/.claude.json`, the activity hooks in `.claude/settings.local.json` and a status line there unless the project already has its own. It also says that, unless a permission policy, a per-seat choice or (for Codex) a named profile applies, the kernel's own seats run with an operational default: Codex with `-s danger-full-access -a never`, and Claude Code with its file tools, web fetches and operational commands, including reads under the home folder, allowed without asking.
`npm run test:repo` (docs guard, first-run references, skill mirrors, context packs) and the packaged-skill identity test pass.
Each usage account row (`rig provider status`, its JSON and the TUI's usage view) gains a sign-in fact, `signIn: { state: "usable" | "needs_sign_in" | "unknown", evidence, at }`, read only from the last refresh, never from usage or windows, and kept apart from availability. Nothing decides on it; a test pins three named files (`subscription-moves.ts`, `seat-restart-in-place.ts`, `seat-lifecycle-service.ts`) against reading it, which is a pin on those files, not a proof about every consumer. It ages from `at`, and a daemon restart forgets it.
- Codex: `usable` when the refresh's rate-limit read was answered for the daemon's `CODEX_HOME` login; `needs_sign_in` when `account/read` reported no signed-in account; otherwise `unknown`, with the failure named.
- Claude: `needs_sign_in` only when `claude auth status` reports no login. A stored claude.ai login reads at most `unknown`, because no existing read shows that it works, and nothing new contacts the service.
- Codex's signed-out answer (`account: null`, `requiresOpenaiAuth: true`, then a failed rate-limit read) becomes the named failure `not_signed_in`, from answers the reader already received.
Changed results:
- A row's unavailability reason now prints whenever its availability is `unknown`, windows or not. The TUI gains this here; the CLI already does on main. The rows that visibly gain a line today are Claude rows with windows.
- A new `sign-in:` line follows "current availability" on every row from a daemon that serves the field; a row from an older daemon prints none.
A refresh that identifies no account doesn't set an earlier account's sign-in: account A's row keeps its observation with sign-in `unknown`, and the current home's `needs_sign_in` is served on its own row in the shape the projection already serves with no reading. Superseded rows never claim "not the current login": a resolved Codex row a later read didn't report reads "not the latest read: a later refresh didn't report this row (another login, one it couldn't identify, or this bucket no longer reported)", and a frozen Claude row reads "not the latest read: a later refresh reported another login, or one it couldn't identify". Tests cover each state for both providers, the reader's shapes, success-then-signed-out, the empty-store control, a dropped bucket and a resolved row followed by an unidentified read. The native signed-out response wasn't run.
A restored auto-mode Claude seat whose first `claude` on PATH is a `#!` script that `exec`s the real binary now resumes ready with its startup context delivered, instead of ending `attention_required` when its conversation has pushed the "Claude Code v" banner out of the captured text. Before, the managed launch froze the script's real path and accepted only a process with exactly that path, which the running binary never has.
- Today's exact-path match runs first. Only when it finds nothing, and only for a frozen launch file that starts with `#!`, a process can be proved by its OS-reported executable path: absolute, named `claude` or the versioned install layout, and agreeing with the OS process name. The script is read for its first two bytes only, never parsed or executed.
- Every other check still applies: the conversation token, a single Claude in the terminal's foreground group under the pane's own process, two matching samples, and one pane whose PID is unchanged. A script-found process is pinned only after all of them pass, and a later readiness check that needs process proof must match the pinned fingerprint, executable path included. A screen that already proves Claude by itself is accepted as it is today for every launch.
- Native binaries, Nix `wrapProgram` launches and a bare `claude` title keep today's results and fingerprints. The new OS path read happens only while proving a script launch.
- For a script launch the binary it runs can't be known in advance, so it is trusted when first seen and pinned from then on.
Tests cover the wrapper-to-binary case, the pin, each refusal (token, foreground group, ancestry, second candidate, missing or disagreeing path, changed PID, start time or path), overlapping checks before a pin, the script detector's byte cases, and the opt-in path read. macOS and a wrapper that spawns rather than execs Claude weren't run.
Refs #1477.
After a failed full restore, replacing the seat's occupant with `rig seat launch --fresh` no longer leaves the seat reading `attention_required` from the old occupant's restore outcome, and `rig seat clear-attention` no longer refuses it with `not_demonstrably_responsive … restore_outcome`.
- Both readers of a seat's restore outcome apply one fence: the event seq of the node's current-generation `seat.fresh_launched` with `status: "ready"`. A restore attempt that started before it describes the retired occupant.
- The inventory (single-rig and fleet) reports `restoreOutcome: "n-a"` for a fenced attempt and takes the lifecycle from the live session. A stored `continuityOutcome` is unchanged.
- Clear-attention finds no restore class for a fenced attempt, so it weighs only startup and pane-identity evidence and never calls the strict reconciler.
- A full restore's attempt starts at its `restore.started` seq; a reconciliation names its attempt by `attemptId`. A zero `attemptId` names none, so the inventory gives it the attempt of the outcome it acknowledges, and clear-attention reads past it as before.
- Nothing is written, and the failed attempt stays in the event history.
There is no fence after an unready or failed fresh launch, a launch from an earlier generation, or two current-generation launches naming different sessions. A restore attempt that starts after the fresh launch applies to the new occupant as usual. Left unresolved on purpose: a subset restore records no start event, so a subset completion after the launch stays current (one before it is fenced); readiness reached after an unready launch, and handover, can't be attributed to the new occupant from the existing records.
Tests in `node-inventory.test.ts`, `seat-attention-reconciler.test.ts` and `seat-fresh-launch.test.ts` cover the fenced and unfenced cases in both readers and the end-to-end fresh launch. With the tests alone on main, the hosted run failed exactly the eight fenced cases.
Refs #116.
Two regression checks now bind each escaped behaviour to its effect, not to an exit code or rendered text. No product runtime, send, restore or refocus behaviour changes.
- Refocus with parked typed work: the scenario creates a real blocked, typed queue row, asserts its state and tags, runs the shipped refocus hook against the scenario daemon, and asserts on the hook's own emitted context. It runs healthy, seeded and healthy in the installed-scenario job, with an active-row contrast. The seeded fault removes exactly one fallback label in a scratch trace child, and admission requires that exact failing hook-context observation.
- Managed compaction arriving as pasted text: `rig compact` refuses stub runtimes, so a daemon test drives the real enforcer command through a version-bound stub input model into the real precompact hook and asserts its restore-pending marker. An 851-character pasted command must lack the marker despite a successful submission. The model encodes one recorded Claude Code version's behaviour, not a universal threshold.
The existing queue-durability controls and passing scenarios are unchanged, and no required check is added. Two regression-log entries in `docs/as-built/arteries.md` record the escaped promises, the layer that missed each, the new check and its limit. Native compaction and restore, hook scheduling, and agent reading aren't exercised.
Refs #1483. Refs #484.
Restore-check now tells apart a saved explicit Claude activity resource, a saved selected core-plugin relay, and neither known OpenRig channel, instead of calling every seat without the explicit resource deliberately hookless. It's a diagnostic correction; it doesn't establish a fix for the reported native incident.
- A saved explicit resource keeps its existing settings and relay findings, and a configured plugin never masks them.
- A saved, readable core-plugin relay reads green as configuration evidence, with native loading and execution unverified.
- Missing, malformed or unreadable selected core assets, or disabled or malformed local settings, read as a yellow caveat. A broken selected plugin adds that caveat while keeping a fully projected resource's evidence.
- With neither saved channel, a resolvable current selection of hooks reads yellow "stale selection", an unknown selection reads yellow "selection unknown", and a resolvable selection without hooks reads green "not applicable" for these known OpenRig channels only.
The added yellow checks can turn otherwise-green output into `restorable_with_caveats` or `ready_with_caveats`. The CLI still exits 0 and offers no repair command, and existing blockers and unknown causes keep their priority. Desired-spec comparison works only for absolute `path:` references; `local:` and other unresolved references read "selection unknown", not stale. A frequent caveat doesn't mean hooks are broken or a seat needs a relaunch. Nothing about restore selection, launch, adoption, hook execution or required actions changes.
Tests cover the hook-selection matrix, the restore-check service and routes, including a fully projected resource with a broken selected plugin, with the restore fixture preserving the original token request, saved context, user settings and hooks, and no startup replay.
Refs #1530.
Community-maintained harnesses use one naming family throughout OpenRig: `rig community-harness list`, `/api/community-harnesses`, presets under `$OPENRIG_HOME/community-harnesses/`, and `communityHarness` selection and status fields. Rig specs keep `runtime`, existing preset runtime names and the built-in runtimes.
- The old command, route, directory discovery and JSON names have no public alias. Existing preset files are left untouched and aren't moved or discovered from the old directory.
- The database column and migration 114 are unchanged and map to the new domain field. Launch-only handling, unconfirmed identity, screen-only activity and the resume/fork refusal are preserved.
- Saved snapshots get a narrow read-only decoder: a nonempty legacy `agentApp` string supplies `communityHarness` only when the new key is absent, and a present new key wins, including an explicit null or a malformed value. Reads preserve the stored bytes, the old key is removed from returned objects, and new capture never writes it. This is storage compatibility, not an old public alias.
Tests cover 11 snapshot-boundary cases (readers, precedence, malformed legacy values, unchanged stored bytes, new-key-only serialization) and five naming and storage cases (API and preset-directory isolation, untouched old preset files, migration 114 fresh and already applied, partial-schema reads). Native harness behaviour isn't exercised; synthetic fixtures stand in for it.
From the shared TUI (`rig tui --shared`) viewed in a Herdr pane, opening a seat's terminal printed `Unrecognised or unavailable hosting terminal: "unknown"` and opened nothing. On Linux it now opens the view in the Herdr of the tmux client that was active last, which is normally the person looking at it.
The TUI runs in the daemon's tmux server, so the person's Herdr markers sit on their tmux client and never reached it. When the TUI isn't itself in Herdr, `open-window.ts` lists the clients attached to its tmux session. If exactly one interactive client was active last, it reads only `TERM_PROGRAM` and the `HERDR_*` markers from that client's environment and passes them to the `rig terminal open --window` child in place of its own.
- Last active is a hint, not the sender of the key: it can belong to a different viewer, and input sent with `send-keys` or by a control-mode client marks no client active. The docs and the code comment say so.
- Every case without a single last-active Herdr client keeps today's result: no client, a tie in the same second, an unreadable or non-Herdr newest client (an older client is never used instead), control-mode clients, and macOS and other platforms. A newest client in a different Herdr session gets the existing endpoint refusal. There's no new refusal or message.
- The CLI is unchanged, so ordinary `rig terminal open` callers keep their environment and desktop-terminal choice.
Tests cover the real read path against a tmux shim and Herdr-marked processes, including ties, unreadable and non-Herdr newest clients, control-mode clients and a failing tmux. `docs/reference/getting-started.md` describes the behaviour.
A current, complete Claude or Codex idle frame no longer has its served `agentActivity` rewritten to `running` just because the terminal repaints. The existing cached capture and idle-layout reader supply the evidence; a generic footer, a stale capture, a wrong target or runtime, or a terminal write in or after the capture's second keeps the existing fallback.
- The frame carries a lower bound on when its bytes were taken: recorded before a single read, and before a whole batch (a batch miss takes a fresh per-seat start). It counts only when that start falls in a later second than tmux's last write, and only while it is under 5 seconds old. A later poll with no new write doesn't invalidate it, and a delayed read can't renew old bytes.
- The idle hook is bypassed only while motion is running, so quiet seats keep the hook's source, reason and event time.
- Current running and needs-input hooks, usage-limit precedence and the existing fallback without a qualifying frame are unchanged. There is no new poller, capture, cadence or silence threshold.
This changes the served `agentActivity` used by JSON, activity filters and graph consumers. It doesn't rewrite the separate activity value that human `ps` and TUI labels may prefer. The agy seats reported in #824 are unchanged: this adds the precedence rule a future agy idle reader could use, without adding that reader. #824 stays open.
Tests use retained Claude Code 2.1.296 and Codex 0.160.0 frames with edited live and streaming rows, and cover later polls without writes, quiet-hook provenance, and deferred single and batch captures through the real structural and motion services.
Refs #824.
`POST /api/transport/send` didn't pass the request's abort signal to the transport. A `rig send --wait-for-idle` whose caller went away (Ctrl-C, or its own timeout) kept waiting in the daemon, and when the seat went idle it typed the text and pressed Enter, though nobody was waiting for the result.
- `/send` and `/broadcast` pass the request's signal to the transport; the server aborts it when the client closes early.
- Before any input, a cancel ends the send as `caller_cancelled`, with `sent: false` and `inputPhase: "not_reached"`, and nothing is typed then or later. This covers a request that arrives already cancelled, the idle waits (including a readiness observation still pending, whose late result is ignored, so the seat's lease is released as soon as the cancellation is seen), the steps before input, and the Enter-only send used by `rig send`'s staged-text retry and `rig walk`. A cancelled Enter-only send types no conversation text and no Enter, though leaving copy mode may already have sent tmux's `send-keys -X cancel`.
- A cancel while queued for the seat's lease is seen when the lease is granted, and the send ends before anything is written or recorded.
- A retry whose delivery id already has a record reports that record's real outcome even if the caller has gone.
- The last check runs inside the tmux adapter immediately before `paste-buffer`, and is reported as `not_reached`, not `attempted`.
- After input may have started, the signal is ignored: Enter is sent as today and the result stays `attempted` or `written`.
- Queue wakes, committed outbox deliveries, draft-aware deliveries once their record is committed, and retained typing-guard delivery are not tied to the caller. Managed preparation keeps its `preparation_cancelled` result.
Changed results: a cancelled `--wait-for-idle` send that used to type later now never types (the unreceived response is HTTP 409 `caller_cancelled`); a broadcast recipient that wasn't started reads `caller_cancelled` and counts among the failed recipients, with its outbox row recorded as failed.
`GET /api/rigs/:rigId/nodes/:logicalId` lists a seat's `installedResources`. For a guidance resource it reported the spec's `target` as the installed path, though `target` is never used to write: the runtime merges guidance into its own file (the rig's Claude managed-blocks file, `CLAUDE.md` by default, or `AGENTS.md` for Codex). A Claude seat declaring `target: AGENTS.md` was reported as installed in `AGENTS.md` while the block was in `CLAUDE.md`.
For guidance entries:
- `targetPath` is the runtime's file only while that file holds an ordered, complete managed block with the resource's id (its BEGIN marker followed later by its END, in the current or older form). Otherwise it's `""`, not guessed. This is evidence about the file at the node's current cwd: no launch cwd is recorded, so it can't tell this seat's write from a block with the same id written there by something else.
- `declaredTarget` carries the spec's `target`, when there is one.
- `effectiveTarget` carries the file the runtime would merge into, when the runtime has one (Claude, Codex).
Other resources are unchanged. The per-runtime file rule now comes from one function shared with restore's guidance check, and restore's own block classification is unchanged. Nothing is written or cleaned up; only the report changes.
Tests in `node-inventory.test.ts` use real files: a declared `AGENTS.md` on a Claude seat; a runtime file without the block; the rig's `CLAUDE.local.md`, Codex's `AGENTS.md` and the older markers; no claim for an unknown runtime, an `append` merge or a half-written block; no claim for a reversed marker pair or an orphan END, in either form; and a changed cwd whose file lacks the block.
Refs #64.
The 15 shipped agent specs that deliver a per-seat role file (`guidance/role.md`, sent at startup) now mark that startup entry `orientation: role`. After a seat's next fresh launch from these specs, `rig queue whoami --json` reports its role binding, and refocus names the role file after compaction. Before this the role read as `not-declared`. Delivery is unchanged: the same file, sent the same way, once.
None of these specs ever projected a role into a shared `CLAUDE.md` or `AGENTS.md`: their profiles select no role guidance, and the unused role guidance declarations stay so a profile that imports them keeps working.
The startup guide now explains how to find and remove, by hand, a leftover shared `role` block that a kernel installed before 0.6.5 may have left in a working directory's guidance file, only after each seat sharing that directory reports its own role. Nothing is edited automatically.
A test resolves every profile of all 15 specs and checks the marker, with resources, startup files and actions otherwise unchanged.
Refs #64.
The TUI Feed now shows open queue updates addressed to a person (pending, in progress or blocked) as informational cards, even when no Slack delivery receipt exists. Critical and urgent updates come before routine ones, with their original priority and detail. The update window is bounded and reports its own coverage, and reading the Feed doesn't change queue state.
Current updates and delivery history keep distinct identities, so the same queue item doesn't show as two cards, and historical receipt details are kept. Decision and blocker selection and the receipt-history endpoint are unchanged.
The critical-first ranking follows the approach in #1055 by @Aschenbath.
`rig doctor`'s cmux warnings now point to the setting that works: in cmux Settings > Automation, choose "Automation mode" for Socket Control Mode, or set `automation.socketControlMode` to `automation` in `~/.config/cmux/cmux.json` (older versions use `~/.config/cmux/settings.json`), then run `rig doctor` again. cmux reloads saved settings itself and doctor re-probes on each run, so no daemon restart is needed. The `rig setup` step, which no longer changes cmux settings, is gone.
When `~/.config/cmux/settings.json` doesn't set the key, the shell and daemon warnings now say that doctor assumes cmux's default `cmuxOnly` and doesn't read `cmux.json`, instead of stating cmux's mode as fact. An unreadable settings file asks for its JSON to be repaired rather than removed, and a probe that fails only in the daemon says so without guessing a cause. Detection is unchanged.
Refs #9.
The Pi/OMP runner now prints its READY marker only when it moves into the ready state. If another successful state reply arrives while the runner is already ready, no second READY line is printed. An error that clears the ready state still lets the next accepted reply announce READY again. The session identity update is unchanged and runs outside this display guard.
This is a defensive guard: no retained trace shows a repeated READY line.
Text streaming is unchanged in behaviour. The existing check that appends only string `text_delta` events is now named `hasDelta`, and a new test keeps cumulative snapshot events from being appended between deltas.
A Codex seat restart that stops at a native startup screen now names it. The launch result carries the resume probe's gate code, and the restart report adds `account.startupGateKind` beside `account.startupGate`, which stays a string or null. For four named gates (hook review, trust, model selection and update) the remaining step names the decision waiting in the pane, says it is for the person or an authorised operator, and gives the recovery command. OpenRig makes none of those decisions.
The report follows the newest screen OpenRig observed: the message, `startupGate` and step describe that screen, not an older one. The one input the resume check may type is the update prompt's skip key (3), at most once. The named and generic steps say whether it was sent, or tried and failed, and a failed send says its effect is unknown. The sign-in and no-saved-session steps keep their own text.
Restart execution is unchanged: no new capture, poll, retry, key or refusal. The plan and every report, successful ones included, gain two JSON fields: `codexNoDaemonFlag`, launch evidence only, and `processMode`, which is `unknown` today and never inferred from the flag. The CLI prints the gate kind and those two lines.
When a failed workflow was redriven, the instance reopened as `active` but kept the failure time in `completedAt`, so the API and the web UI showed a running redrive as completed.
The instance store's frontier update now clears the completion time whenever the next status is `active` or `waiting`. A terminal status still keeps its existing time when no new one is supplied. A regression assertion covers the redrive.
The getting-started guide now has a short "Built by the community" list, after the section on viewing your team. Its first entry is Rig HQ by dmelo, a read-only pixel-office viewer for rigs, seats, attention and queue handoffs, which its author reports testing with OpenRig 0.6.5.
The list says that these tools are maintained by their authors, that we haven't tested them, and that it doesn't promise support or compatibility with your OpenRig version. Setup and support questions go to each tool's repository.
`rig grow <rig>` and `rig remove <rig>` now accept a rig name as well as an id, through the shared resolver `rig add` already uses. An exact id resolves first, even when a rig's name equals that string. A name that more than one active rig shares is refused before any change, listing the candidate ids. If the rig list can't be read, the commands fall back to treating the input as an id, and the error says the list couldn't be read and suggests passing the rig id (`rig ps --json`).
Before this change a rig name was passed straight through as an id and failed with not found.
Tests cover a name, an ambiguous name, an exact id and an unreadable rig list for both commands.
The warning for a protected projection target now names only remedies that exist: fold the operator edit into the spec source, or move the edited target aside before the next projection. It no longer tells the operator to re-run projection with `--force`, which no command offers; force is an internal parameter that no CLI option or route passes. The `hash_conflict` warning already named the same two remedies, so the two branches now agree.
No flag is added, and protection, conflict classification and the internal force path are unchanged. Two code comments now distinguish internal force from operator commands. The test checks that the warning names both remedies and doesn't mention `--force`.
README's "What OpenRig changes on your machine" now matches the writers it describes: startup writes Codex's managed config only when a Codex seat is recorded (otherwise it removes stale managed hooks), the first Codex launch ensures the config, a user's own Claude status-line command wins, and the warning about settings files is narrowed to the writers that actually recover unreadable files, without a product-wide preservation guarantee.
Error and help text now point to steps that exist:
- the protected-compaction refusal for an unrecorded pane names `rig reconcile-session <session> --no-launch`, notes that it records a new claimed session row with unverified conversation continuity, keeps input protection on, and points to `rig context get help` when the pane can't be resolved;
- `rig provider` prints the daemon's error message instead of a bare code, and prints a seat's unavailable reason even when it has windows;
- `rig scope --workspace` help states that a missing tree falls back to the configured root except for `mission create`, which creates the named tree, and `approve`, which refuses;
- config help tells a complete `--full` payload apart from the usual projections, including `queue transitions --full` and whoami's `--all-peers`;
- Pi cost switches are described as taking effect at a seat's next launch or resume, provider usage windows as off by default with the command to enable them, and cost history as `--json` only.
Human output and help text only: JSON output, exit codes, behaviour and path selection are unchanged.
`rig seat restart --keep-conversation --help` and the capability map's restart entry now describe today's behaviour for an adopted seat, one OpenRig didn't launch: an in-place restart refuses with `claimed_session`.
They also say which conversation a restart can prove. The stored conversation must match the one the running Codex's command line names, or, when it names none, the only one Codex has logged since it started. OpenRig can't tell which conversation Codex is on now, so if the conversation was changed inside the running Codex, the restart may refuse or resume the earlier one; check the pane with `rig capture <seat>` first.
Text only; no behaviour changes. The generated context packs check clean.
When several `--wake-after` timers for one seat's parked rows fall due in the same watchdog scheduler pass, the seat now gets one message naming every row and its summary, instead of one interruption per row.
What's grouped: an ordinary parked-row reminder, a one-shot timer that serves exactly one live parked row and carries the default message. Unchanged: a single due reminder (same text, same delivery ID, same path), a timer with a custom `--wake-message`, a watchdog shared by or attached to several rows, a repeating wait, a provider-limit timer, every other policy, and `evaluate` called outside the scheduler. Grouping is within one pass only: nothing is postponed, jittered or re-cadenced, and reminders that fall due in different passes still arrive separately.
Before every send in a seat's group, each remaining held reminder is checked again (job still active, the stale-park guard, still eligible to join a digest), with nothing awaited between the check and the send. A reminder that already has a delivery record of its own, including a lone send cut short before it was accounted, goes alone through its own delivery ID and reads the earlier receipt back without writing. A digest whose members can't all be bound because one already has a record is refused before any input, and no member is accounted as attempted. A storage error while binding is reported as a failed attempt for every member, as a lone wake's storage error before input is today.
Every member is bound to the one attempt before any input: one outbox entry per member under its own delivery ID, committed and claimed in one transaction, then one send through the session transport and typing-guard handling. If the daemon stops or accounting fails before every member is recorded, the next pass accounts each attempted member from that record; an attempted member is never sent again, alone or in a new digest, and a reminder that was never attempted still goes. Each member keeps its own history row (its own line plus the digest's ID and size), schedule, actionable state, "park wake fired" receipt and one-shot retirement; a retained, failed or timed-out attempt is accounted to each member as a lone reminder's would be. A stop is honoured before every send, between seats and within a seat's group: the send in flight finishes and is accounted, and every reminder not yet sent stays due.
The watchdog's delivery moves from `startup.ts` into `domain/watchdog-delivery.ts`. Costs: one outbox existence check per remaining eligible member, and the full digest text stored in each member's outbox row; a held digest lists once per member in `rig seat held-messages`.
Tests: `watchdog-park-reminder-digest.test.ts` (15 cases through `createDaemon`) and `watchdog-delivery-guarded.test.ts` (3 cases on the real outbox and draft-aware transport, including a lone send cut short and the store reopened). Nothing is sent to a live seat.
The capability map now names `rig usage top` next to `rig usage series`, and how to compare token use across machines with it: run it on each machine, or against each machine's daemon with `OPENRIG_URL`, back to back, with the same `--window`, no `--top`, and `--json`.
For each machine the agent reports the host, the window and its `sinceIso` (each daemon counts from its own clock), how many seats were ranked and over what span, the unknown seats and their reasons, and each seat's runtime, declared `model` and last observed model (`modelObservation`) from `rig ps --nodes -A --json`. A seat that has reported usage before but has no usable samples in the window is listed as unknown with its reason, never as zero; a seat that has never reported usage is in neither list, so the agent names each seat that `rig ps --nodes -A --json` shows and `rig usage top` doesn't as not covered. Machines aren't added into one total unless they cover the same seats over the same span.
No collector, endpoint or command is added. Docs only: the generated context packs and their mirrors check clean.
User-visible text now says "harness" for the agent software around a model and "community harness" for a harness someone outside OpenRig maintains and installs as a preset. "Runtime" stays the broad category: the rig spec's `runtime` field and the TUI's runtime column, which also cover terminals and stubs.
Changed wording: `rig agent-app`'s help and list output, the `rig ps` line for a community-harness seat ("Community harness (<seat>): …"), launch warnings, the fork, resume and start errors, the readiness reason, preset problems shown by `rig agent-app list`, `rig ps` and `rig up` preflight, the restore decision and error, one startup error, `docs/reference/agent-startup-guide.md` and `ROADMAP.md`. The human sentence in `agentApp.needsInput` now reads "from the screen only: no harness prompt pattern".
`docs/reference/topology-naming.md` gains a table of what a seat runs: runtime, harness, community harness and terminal.
Unchanged: the `rig agent-app` command, `GET /api/agent-apps`, the `agent-apps` preset folder, the `agent-app` runtime ID, the `agentApp` key, the `app_not_running` code, migration 114 and the `sessions.agent_app` column, and every decision code and condition. Internal identifiers keep their names until that code is next changed.
Tests: three test files update their expected messages. The rendered docs and this branch's CLI help weren't checked by running them.
On Linux the daemon now reads the process table from `/proc`, on a worker thread of its own, instead of forking `ps`. Native process lineage and the resume-metadata refresher both call one `readProcessTable()`; other platforms keep running the same `ps` as before, in the C locale.
The rows are the ones `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command` prints in the C locale: the task name cut at 15 bytes, `lstart` from `btime` plus the start time in ticks, `[name]` (and ` <defunct>` for a zombie) for a process without a command line, control characters as `.` and each byte of a non-ASCII character as `?`. Callers asking while a read is in flight share it; a later call reads again. The files are read on the worker thread and sent to the event loop in parts of 100 pids, so a `cmdline` read that waits on its process's memory lock holds that thread, not the event loop. If the reader thread is lost, only the reads sent to it are rejected.
Failure behaviour: a pid whose `stat` is gone (ENOENT, ESRCH) or hidden from the daemon's user (EPERM, EACCES) is left out, and a `cmdline` that can't otherwise be read keeps its process as `[name]`. Any other `stat` read error rejects the whole table read, and callers handle it as they handle a `ps` that fails: the strict census rejects, and best-effort listing returns no rows. This is stricter than `ps`, which skips one unreadable pid and lists the rest, so for a per-pid fault such as EIO an observation can lose otherwise readable processes that `ps` would have returned.
Measured by the author on Linux 7.0 with 238 processes: a table read went from about 69 ms of CPU and a fork of the daemon to about 8.4 ms and no fork. In a Linux container the event loop stays busy about 1.3 ms per read, against 5-6 ms with `ps`.
Tests: a fake `/proc` helper runs the process-table scenarios through the `/proc` path on any CI platform, including a gone `stat` that's left out and another `stat` error that rejects the read. Not run: a native Linux comparison under hidepid or with a real per-pid fault. macOS keeps `ps`, unchanged.
A joined Claude account row no longer reports `currentAvailability: "known"` from readings it can't attribute to that account.
The row joins the freshest status-line sample across seats to the daemon's last `claude auth status` login. A sample records a seat, a window, a percentage and a reset, but no account, and nothing links a seat's reading to the login it used when it sampled. So two situations read exactly like the right account: a seat on a different login whose window has the same reset, and a seat still on an earlier login after the daemon's login changed. In both, the row read "known" for an account whose room it hadn't measured.
Changed result: with `provider.usage_windows` on (it's off by default), the joined Claude row's `currentAvailability` is now `"unknown"`, with `unavailableReason` "no sample-time link between these seats' readings and this login: a seat on another login, or still on an earlier one, reads the same, so this account's availability isn't known from them". Until a sample-time seat-to-account binding exists, that applies to every joined Claude row. "Unknown" isn't zero allowance, and it changes nothing about running work.
The reason is in the structured row (JSON and the cross-host shape). The CLI and TUI print `unavailableReason` only for a row with no windows, so with windows present they show "current availability: unknown" with the existing identity note, not the new sentence.
Unchanged: each window's reading and the seat it came from, the identity caveat that the seats aren't proven to share this login, the differing-reset guard, the frozen rows for earlier logins, and other providers.
Tests: two cases in `provider-claude-usage.test.ts`, the same reset on another login and a seat still on an earlier login, both of which read "known" before this change and read "unknown" with the reason after it. One existing assertion changes from `known` to `unknown` plus the reason; that is the changed result. These are hermetic input cases; a native two-login check was not run.
Managed launch preflight now refuses a session name tmux would rename, before any session is created.
A rig name ends every seat's session name (`{pod}-{member}@{name}`), and tmux replaces `.` with `_` in a session name. So a rig named `demo.v2` launched a seat that OpenRig recorded as `[email protected]` while it ran in a tmux session called `dev-impl@demo_v2`. Every later probe on the recorded name failed (`can't find pane: v2`), so the seat couldn't be observed or reached, and its tmux session was left behind. The new error names the rewritten session and says to use a rig name without dots.
Where the check runs:
- the pod path, `preflightValidatedSpec`: `rig up`, adding a member to an existing rig, first-start retry, bundle install and bootstrap, expand, and the preflight preview;
- the legacy path, `RigSpecPreflight.check`, before `NodeLauncher.launchNode`. Its tmux collision probe now skips a name this check already rejected, because a real `has-session` on a dotted target fails with `can't find pane` and would throw before the clear error is returned. Ordinary names are probed as before, and a real collision is still reported.
An existing dotted rig gets this error when a member is added or a first start is retried. Unchanged: materialize-only (`rig import --materialize-only` and `rig adopt`), which creates no session and still creates a dotted rig; explicit attach; restore and stored identities; spec validation, spec review and bundle assembly; and session-name parsing and its character set. Paths that launch without preflight, such as `rig seat launch`, handover, restart and restore, aren't touched by this change.
Tests: `tmux-renamed-session-preflight.test.ts` covers pod `rig up`, legacy instantiate and adding a member to a dotted rig, each refused before `createSession`, with controls for names without dots, validation and review; materializing a dotted spec without creating a session; and the legacy probe skip, with an ordinary collision still reported. `retry-first-start.test.ts` covers the first-start retry, and `pod-bundle-assembler.test.ts` keeps a dotted rig assembling into a bundle. The tmux rename itself was observed on tmux 3.6 only; the check is a string check and doesn't call tmux.
`rig seat set-cwd <seat> --cwd <absolute-path> --reason <text>` saves an existing seat's working directory for its next fresh launch or rebuild handover. The seat's node, address, queue and history stay as they are. Until now the only routes were recreating the seat, which loses its history and handover lineage, or editing the database by hand.
This carries #1154 by Aschenbath, in the smaller shape its review asked for. Their work is the validation, the audited save with its concurrent-change check, the read-only destination check, the route, the CLI verb and `launch_cwd` in `rig seat status`.
What it does:
- Saves the directory and a `node.cwd_changed` audit event (from, to, reason, operator) in one transaction, with a concurrent-change check.
- Creates the folder if it's missing and writes nothing else there. The next launch writes the startup files, as it does today.
- Before saving, it reads, without writing, the enumerated resource copies and startup skills that launch would place there. It refuses with `cwd_destination_conflict`, naming the paths, for an operator-edited file, an unrecorded file with different content, or two sources for one destination. That's stricter than launch, and it's a point-in-time check, not a guarantee against later changes; at adoption, launch applies its own policy. A refusal can leave the new folder created. An agent app takes no startup files, so none are checked for it.
What it doesn't do:
- The saved directory doesn't move the running process. Adopt it with `rig seat launch <seat> --fresh --stop --reason <text>` (a blank conversation) or `rig seat handover <seat> --source rebuild --reason <text>` (carries durable context). An in-place Codex restart isn't adoption: it keeps the directory it observes from the running process.
- Until then, background work can already use the saved directory. For a running Claude seat, collector provisioning writes `.openrig/context-collector.cjs` and merges `.claude/settings.local.json` there, under its existing preservation rules, and managed compaction, manual or automatic, asks the seat to write its restore map there, which may be outside its current workspace. These are examples, not every consumer. Adopting promptly is guidance, not a guard against a poll or compaction that runs first.
- Before adopting, `rig down`/`rig up` or a fork handover resumes the existing conversation in the saved directory, and Claude keeps conversations per project directory, so the resume may not find it (not run natively). Files in the old directory are left as they are.
Tests: `seat-set-cwd.test.ts` (35 tests; real SQLite and the Claude, Codex, Pi, OMP and stub file adapters) covers the save and its event, symlinked destinations, refusals with no seat, custody or startup-file change (invalid input, missing or malformed startup context, an ambiguous seat, and destination conflicts for each runtime and resource kind), concurrent changes and audit-write failure leaving the old directory saved, the typing guard, and a community agent app's seat. The route, CLI lifecycle, handover-source and stub CLI journey tests pass. Native Claude or Codex conversation resume across directories was not run.
Refs #1154
Refs #762
Co-authored-by: Nash Aschenbath <[email protected]>
`GET /api/queue/list` now trims each comma-separated state name, so a spaced list such as `rig queue list --state "pending, in-progress"` (or the MCP `rig_queue_list` tool with the same value) returns both states' rows instead of only the pending ones.
Everything else keeps today's results: a name that isn't a queue state still matches no rows, alone or beside a known name; an explicit filter of only spaces or commas still matches nothing; and an absent `state` or an empty `state=` keeps its default, on the plain and `attention=1` paths and with `activeOnly`. This is a partial whitespace fix: a misspelt state still answers an empty list.
The new test covers the spaced and padded lists and each preserved case on both list paths, including with `activeOnly`.
Managed `/compact` on Claude seats runs as a command again, waits for an idle seat, and reports honestly what happened to it.
Since 0.6.10 the managed `/compact` grew past what Claude Code takes as a slash command in one bracketed paste (a native comparison on Claude Code 2.1.282: 792 characters ran, 850 arrived as pasted text), so compactions arrived as text and didn't run while the attempt read as delivered. The automatic path also didn't wait for an idle seat.
The change:
- A 600 UTF-16 code-unit budget for the command. It always keeps the managed-compaction prefix the hooks recognise and a short trust note, then the map path and the policy's instruction if they fit. What doesn't fit moves to the restore map or the restore request, including the full trust-channel note. Bracketed paste is kept.
- Automatic `/compact` waits for a pane-confirmed idle seat, as the manual path does, within the existing preparation deadline.
- A new `compact` result (`not_sent`, `submitted_unconfirmed`, `uncertain`) beside the preparation prompt's `delivery`, in the compaction state, the trigger route and the CLI. `stage` values are unchanged, and nothing claims compaction ran.
Changed results against main:
1. Shared transport: a guarded send whose text was written before an exception returns `sent: true` / `outcome: failed` / `inputPhase: written` (it returned `sent: false`); an issued but unconfirmed write returns `outcome: failed` / `inputPhase: attempted`. For an issued-write exception with no `code`, `rig send` gets HTTP 409 and exits 1, where it got 500 and exited 2. Queue classification is unchanged.
2. An established pre-input `draft_*` refusal ends automatic compaction as `not_sent`; a refusal after a paste stays `uncertain`.
3. An attempted or written `/compact` ends `uncertain`, with no automatic replay. Only a known pre-input idle deferral re-arms.
4. `--skip-map`: the restore request names a map completed at the attempt's path (final file, then `.tmp`, matched on the marker); otherwise it says no map was required.
5. Wording on every send: a failed Enter receipt says the submission outcome is unconfirmed, not "not submitted"; "Enter was not sent" only when the throw came before the Enter key reached the adapter.
6. Automatic `/compact` waits for a pane-confirmed idle seat.
7. A compaction-only refusal, `pane_unknown_protected`, for a running draft-aware seat whose binding records no tmux pane, on both paths, before any preparation or `/compact`. The manual trigger answers HTTP 409 where it answered 502, `rig compact` exits 1 where it exited 2, and the response carries no `preparation` or `compact` field for this refusal. A fresh attempt leaves no outbox rows or input effects; an attempt already under way is stopped and keeps its earlier `compact` evidence, and no old outbox rows are cleared. The check reads the guard's mode the way an automatic lease chooses it (desired draft-aware, else effective), so during a mode switch the manual path's refusal reason can differ; nothing is written to the seat either way.
The premise behind item 7 was corrected in review: compaction never reached a protected seat with no recorded pane, idle or busy, because the input layer already refuses every write to a managed seat without a known pane. So the refusal removes nothing that works; it makes that case explicit and stops it leaving held `/compact` copies.
Tests run through the real `SessionTransport` with a recording tmux, real draft-aware delivery and an outbox on a migrated database, including a real zero-input transport refusal and the pinned pane-less cases. Native compaction was not run on the build machine; the 600-character budget leaves about 190 characters of margin under the measured 792.
Refs #27
`rig ps --nodes` and `rig roster show/find` report the current occupant's last observed native model separately from its configured model, with the evidence source and native record time.
Claude and Pi identify assistant-response evidence; Codex identifies recorded turn configuration. Missing, stale-occupant or ambiguous evidence stays UNKNOWN, with no fallback to the configured model and without transcript contents or paths.
Inventory reads use two bulk pane snapshots and a shared process census. Unchanged file facts are cached by occupant and record identity and metadata, while current-record selection and the final occupant checks still run. Duplicate roster routes keep the agreed registry fields when only model observations disagree. Compact JSON uses `[model, source, native-record-time]`, with explicit UNKNOWN reasons; full JSON keeps the complete labelled object.
Unknown reasons for the final read: a failed final PID lookup or an unavailable final bulk snapshot gives `native-read-unavailable` with a null model, and a null final PID or a pane missing from a successful bulk map gives `pane-unavailable`. Only an observed generation, name, binding or PID disagreement gives `occupant-changed-during-read`. Identity is still revalidated after the final await.
Known limit: bulk pane queries avoid per-seat tmux queries, but total work still scales with seat count. Selection, stat and metadata work remain, and growing records invalidate cached facts and trigger synchronous bounded reads and parsing for the affected seats. The 8 MiB Codex scan span is neither a fleet latency budget nor an aggregate I/O bound, and the fixture checks are not a fleet performance measurement.
Tests cover final-await turnover, the final direct and bulk read failures, unchanged-file caching with append and replacement invalidation, bulk inventory costs, roster preservation, and realistic observed and UNKNOWN payloads under the unchanged compact-size guard.
`rig capture` now says which text in a Claude Code or Codex seat's input box is likely a harness suggestion (ghost text) rather than something the person typed.
A plain capture drops the cursor position and the faint styling that tell a suggestion from a draft, so an agent could take a faint suggested `/compact` in another seat's box for the person's unsent draft. For Claude Code and Codex seats, capture now reads the visible screen once with styling, with the cursor read before and after, and describes only the input box it recognizes: Claude Code's prompt row inside its frame, or Codex's box.
What changes against main:
- `rig capture --json`, the capture route when the request asks (`inputBox: true`), and the MCP capture tool gain an `inputBox` field for Claude Code and Codex seats: `status`, `confidence`, `cursor`, the `input` and `suggestion` regions, `evidence` and `hint`. Other seats, and callers that don't ask (the send path's staged check, `rig walk`, the web preview routes and attention recovery), are unchanged.
- `rig capture` prints one stderr note for an agent seat whose input box isn't confirmed empty, and its help gains one bullet. Stdout is unchanged.
- Requests that ask make up to three more read-only tmux reads per agent seat. A single capture's box reads are bounded at 1 s, and a rig or pod capture shares one 1 s budget across its seats; seats past it report the box unread.
- The `mission-slice-sop` skill's ghost-text gotcha points agents to the note, and its edge digest is updated.
- Local `rig capture` asks, so slow tmux can add about one second of box reads to an ordinary capture. The budget bounds the box reads, not the whole request, so a capture already close to the client's 5 s timeout can now cross it.
- A box read that hits the budget isn't cancelled. At most one per request carries on, read-only, until tmux answers.
An empty box reads empty with high confidence only when the cursor is steady inside it. A cursor outside the box, one that moved during the read, or one that couldn't be read gives `unknown`, and a single cursor reading can only lower confidence. It's a heuristic: a whole frame drawn in output can still be recognized as a box, and the field is evidence for an agent, not the person's intent.
Tests run the rules on recorded Claude Code 2.1.296 and Codex 0.160.0 frames with styling and cursor added per case, including the empty and partial-cursor cases. Route cases check that the box is read only when asked, and that a rig capture's reads share one budget: the input-box reads consume about one second in the fake-timer route fixture, whose content capture is immediate.
An aborted workflow instance now shows ⊘ ABORTED on /workflows and in a spec's runs band, instead of ○ COMPLETED. The UI's workflow status type now includes the daemon's `aborted` status, and the status chip has an ABORTED case before its fallback. Aborted instances keep their existing ranking alongside completed ones.
The instance page's position card and the /workflows header count still treat aborted as on main.
Verified with a render test: an aborted row reads ABORTED and not COMPLETED, a completed row still reads COMPLETED, and the ranking is unchanged. Not checked in a browser against a live daemon.
The rigs skill's join offer now says which tmux effects were hand-tested and which come from the source, and what the agent's address is inside tmux.
Inside tmux on 0.6.11, the agent tells the person that its sender and reply address is their existing tmux session name, even a number like `0`: `rig attach --self` returns that address, `rig whoami` confirms it, and `--display-name` does not change it.
The tmux effects list is relabelled against a hand test on 0.6.11. Transcript recording and, for Claude Code, writing `.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were observed. Other agents typing into the window, `rig down` closing the tmux session, and updating an existing `.claude/settings.local.json` while keeping its custom status line stay labelled as from the source. Before, every tmux effect was labelled from the source.
The plain-shell recommendation and its effects are unchanged, and the packaged copy is identical. Text only.
When an agent closes a human decision and its successor can't be verified, the new custody finding now goes to the agent that closed it, not to the person. The owner comes from the actual closing transition, including archived history and handed-off closures; later notes and notification receipts don't transfer it. When the closer is human, system machinery, missing or not admitted by this host's queue, the finding goes to the asking agent if its address is admitted, and otherwise to the original destination. Human identity is judged independently of terminal bindings, through the queue's existing address check. An agent destination keeps its existing route.
Changed result: newly created dangling-closure and successor-verification-required findings for a human destination route to the closing agent (or the asking agent) where they went to the person before. Existing findings keep their routes and deduplication.
When the human registry can't be read, a custody candidate whose owner can't be classified keeps its original destination, with one log line naming it. A pass with no finding to save for an <name>@external person keeps routing agent-bound work and closing repaired findings. A pass that must save a finding for an <name>@external person still fails at that finding, as before; findings not yet created in that pass, and the closing of repaired findings, wait for the next pass that completes, which then creates the finding for the closer. The as-built coordination-primitive doc describes this.
Verified with the sweep and queue tests, including a red-then-green handed-off closure case and the @external outage case. Live Slack delivery wasn't exercised.
Refs #514
Co-authored-by: Nash Aschenbath <[email protected]>
Codex's current `Trust this folder?` panel (the heading plus the numbered `Trust and continue` choice) is now recognized as a workspace-trust gate, as the older wording already was. Readiness and resume report that trust approval is pending instead of a false ready or unknown boot progress. That holds when an earlier composer, status footer or Codex header sits above the panel, and when a heading-only fragment follows it; the latest complete panel decides. A later composer or model status footer still supersedes an old panel, with the existing whole-text footer match kept, and the older wording keeps its matching and its place after the active-session check.
Changed results:
1. A live current-format trust panel now reads trust_gate: alone, with Quit selected, followed by a heading-only fragment, or below an earlier composer, status footer or Codex header. Before, it read awaiting_runtime, or resumed when an earlier composer sat above it. A resumed reading let startup text and Enter reach the panel, where Enter means "Trust and continue".
2. Three screens that were already not ready now name trust as the reason: a stale panel and composer followed by an unindented or one-space footer after a single blank line, and a quoted complete panel above a live numbered approval prompt.
The fixture is Codex's own rust-v0.156.1 trust-directory snapshot. Verified with fixture-driven classifier tests and mocked adapter readiness and resume, including that no trust-choice input is sent; no live Codex run.
Refs #227
When a person's own Claude Code or Codex sets up OpenRig, the rigs skill now has it offer to join this session to the team before its first message to the operator, so the operator sees a named teammate instead of "unknown sender". The person says yes or no, and on a no the operator handoff carries on.
In a plain shell the agent recommends joining, and the list says what was measured there: joining names the member and its messages, writes no files in the project or personal settings, other agents reply through the task queue because they can't type into the window, and stopping the team leaves the session running.
Inside tmux the agent offers joining as optional, and the list says what the code does there (from source, not hand-tested): other agents can type into the window, stopping the team (`rig down`) also closes that tmux session, and it can record the session's transcript and, for Claude Code, write a status-line collector into the project.
The first-contact command names where the kernel's rig ID comes from (`rig ps --json`). Everything else in the skill is unchanged, and the packaged copy is identical. Text only.
A rig bundle can be shared, and anyone with it can read what's inside, so `rig bundle create` no longer packs a harness login file by accident. It refuses `.claude/.credentials.json`, `.codex/auth.json` and `.pi/agent/auth.json`, at the top of an agent folder or carried project or nested anywhere inside one, and the message names the file, says anyone with the bundle could read it, and says how to fix it: remove it and build again, or, only if recipients should have this login (for example, your own internal test bundle) and its owner agrees, recreate with `--include-login-files`.
That opt-in is off by default. It records the exact archive paths of the login files actually included (`included_login_files` in the manifest), and verify, unpack, inspect, the install plan and bootstrap accept only those recorded paths, each with a visible notice. An unrecorded login file, an extra file, a malformed, directory, wildcard or traversal record, or a hash mismatch still fails. The record is the creator's unsigned declaration, not proof of consent, trusted origin or safe credentials. There's no machine setting, force or wildcard.
The check extends the existing shared sensitive-file predicate and integrity verifier, so ordinary `auth.json` files elsewhere and other harness files are unaffected. One fixed-time ordinary v2 bundle and one v1 bundle were checked byte-identical to main's.
Changed results:
1. A bundle that contains one of the three login files, created today without the flag, is now refused, and no archive is written.
2. Existing archives that hold one of those files without a record now fail verify, unpack and install (install returns 400, even with --force or --skip-version-check). Inspection stays a 200 diagnostic with integrity false, and the CLI exits 2.
3. The CLI author check now reports login-file findings, and the CLI and web bundle inspector now show integrity errors.
4. Inspecting a v1 bundle whose manifest can't be parsed or normalized now returns the 200 diagnostic ("Invalid v1 manifest: …"), the same as v2 already did, where it used to fail with 500. Install, verify and unpack are unchanged for those.
Notices and errors name paths, never file contents.
The README's overview line read as if the whole project were in maintenance mode. It now says that OpenRig itself is under active development, and that only the older React web UI is in maintenance mode, with best-effort support. One line, text only.
Refs #55
Tests now runs on every push to main, so the commit main actually lands on gets a direct check after each merge. Until now only pull requests and manual dispatches ran it, and main's health after a merge was inferred from later PR runs.
- A push to main runs the four existing Ubuntu jobs on the pushed commit: build-and-package, typecheck, repo-checks and installed-scenario. The macOS package-tests matrix doesn't run on push (`if: ${{ github.event_name != 'push' }}`), and its skipped state isn't a pass.
- Pull-request and manual runs are unchanged: all eight jobs, with the same names, so the required checks are unaffected.
- The concurrency group now includes the event name (`tests-<event>-<ref>`). Main pushes still coalesce among themselves, so a burst of merges runs about one full Linux pass rather than one per tip. A push can't cancel a manual run on main or any PR run.
What this does and doesn't prove: Linux checks of main after merges, not full cross-platform testing, and not every intermediate tip. A cancelled or skipped main run stays untested. No ruleset, required check, job body, macOS job or third-party action changes.
A seat can give a slow refocus trace more time, and a slow attempt is reported honestly. This is a budget-and-reporting change, not full refocus parity.
The switch is `OPENRIG_REFOCUS_BUDGET_MS` in a seat's environment, from 4,500 ms (the default, today's behaviour) to 52,000 ms. With a budget set, each lookup's cap scales with it (the trace gets about 23 s at 52 s) and every lookup is clipped to what's left, so a slow attempt ends as a reported gap. A single 45 s trace isn't provided. With no budget set, the lookups keep their fixed 2 s caps and the old-CLI `whoami` retry.
Changed results:
1. The five `refocus.cjs` registrations (both harnesses) allow 60 s instead of 5 s, so a refocus hook that hangs outside its lookups can hold a prompt for up to 60 s. Every other hook keeps its registration, including Claude's PreCompact at 30 s.
2. A default fire whose lookups together pass 5 s now completes (about 6 s in the measured case) where it used to be killed with nothing delivered. The timing depends on which lookups are slow and on the old-CLI retry, so default outcomes aren't identical to before.
3. openrig-core moves from 0.1.12 to 0.1.13 in both manifests, carrying only this hook change, its registrations and its doc, so existing vendored installs pick it up through the normal update path.
4. Not resolved: two hook runs for the same seat and occupant are still not serialized. If they overlap, a PostCompact recorded during a delivery can be lost, and one refocus can be delivered twice. Tests reproduce this between hook processes and characterize it. Whether a harness overlaps these events for one session isn't established, so the gap is open, not shown to be safe.
5. The failure report gets its own 1.5 s after the attempt's lookups, measured from the actual elapsed time, so the third failure of a slow default attempt now files its issue where it used to be cut off. An unconfirmed report keeps its id for the next attempt, as before.
`docs/reference/refocus-channel.md` documents the budget, the registrations and the unresolved overlap.
Agents in an OpenRig team now brief a peer according to the model it runs. `delegating-work` has a new section, "Check what kind of agent you're handing it to". Before handing over open-ended work, check the peer's model: `rig ps --nodes --json` shows each seat's declared model (`model`, or `null` when none is declared), and a declared model can differ from the running one, so ask the seat when unsure.
A goal-oriented model gets the goal, why it matters, what good looks like, the requirements and the context, and finds the route itself. Other models, and any model you can't identify, get smaller, clearer pieces with steps, boundaries and a result you can check. A model the rule doesn't place goes in the second group until you know where it sits. Never tell a model which group it's in. "Hand over intent, not instructions" now splits by model class.
The `openrig-skills` router line and one `queue-handoff` checklist line point to the section, and the skill mirror and edge digests are regenerated. openrig-core moves from 0.1.11 to 0.1.12 in both manifests, so existing installs pick up the change. Text only.
TUI helper actions keep working after the folder `rig tui` started in is deleted. The TUI passes an explicit working folder to the eight helper processes it starts: the timezone setting, the crash-cart probe, three daemon-start forms, local reading, spec launch and terminal-window open. One small resolver keeps the current folder while it exists. If the folder is gone (`ENOENT` or `ENOTDIR`), it uses the home folder. For any other error it leaves the folder unset, so the helper inherits it as before. The TUI itself never changes folder. Arguments, environment, the explicit launch folder and error handling are unchanged.
Results that change for the same input:
- With the start folder deleted, the helpers run in the home folder, where they failed with `ENOENT: uv_cwd` until the TUI restarted.
- A folder deleted and recreated at the same path now gives a helper the new folder, where it inherited the old one.
Refs #1479.
A seat can now opt into a draft-aware typing guard: `rig seat set-typing-guard <seat> --mode draft-aware`. Every seat stays off by default. In this mode, automatic input checks the seat's input box just before typing and again before Enter. A seat with an unsubmitted draft isn't typed over: the delivery is held or refused with its reason, and nothing is retried automatically. `hold` keeps its current behaviour, and the legacy on/off setting maps to hold or off.
This re-lands Nash Aschenbath's reviewed work from #1072 on current main, with commits also from Mr-Neutr0n (whose commits carry the name Harikrishna KP). The original commits, with their authorship, stay on this pull request's branch and in #1072. The squash adds a merge of main with its conflict choices, a helper rename that merge needed, test updates, and one follow-up: a draft-aware seat now skips the idle wait outside the seat's lease, as a hold-mode seat already does, so a draft or an unreadable input gets its immediate result.
Limits that ship with it:
- Off by default isn't inert. Migration 100 and the shared transport and startup changes apply to every seat.
- In draft-aware mode, an empty input waits for idle inside the seat's lease, which can delay other work for the same seat. The empty-input test starts from an idle seat, so lease occupancy during a long busy wait wasn't measured.
- Turning draft-aware off between the input snapshot and the lease can still take that in-lease wait. This was read from source, not exercised.
- The gap between the last observation of the input box and typing remains. There's no guarantee against text a person types at that moment, and no automatic retry or replay of uncertain input.
- Native harness behaviour isn't newly proved here. The evidence is in-process tests and CI.
Refs #1072
Co-authored-by: Nash Aschenbath <[email protected]>
Co-authored-by: Harikrishna KP <[email protected]>
Co-authored-by: Mr-Neutr0n <[email protected]>
The model-divergence monitor reported a divergence for Claude seats that were running exactly the model their pin asked for, in two cases: a pin carrying the `[1m]` context-window option, and the `fable` alias, which named an older model.
For Claude seats only (runtime `claude-code`), one recognized trailing `[1m]` on either side is now set aside before the existing comparison, so `claude-opus-5-5[1m]` matches an effective `claude-opus-5-5`. Other suffixes, embedded text, partial or unknown aliases and every other runtime stay literal. The shared alias map now names `fable` as `claude-fable-5-1`, for both the comparison and the spec advisory. Pi's pin readings are unchanged, and real divergences still alert with the original pin and effective strings.
Results that change for the same input:
- A Claude pin with `[1m]` whose model matches the effective one: no divergence notice, where the monitor reported one.
- A `fable` pin with effective `claude-fable-5-1`: no notice, where the monitor reported one.
- A `fable` pin with effective `claude-fable-5`: now a divergence notice, where the monitor matched, because the alias names the current model.
- The spec advisory for `fable` recommends `claude-fable-5-1`; validity and errors are unchanged.
Refs #1403.
The Slack connector drops every post made by a bot, so another app's bot (an AI assistant, an alerting app) can't reach an agent. This lets an operator name such bots: their posts in one channel, the bot inbox, reach one seat as rows from the bot.
## What changes
A new optional `botInbox` block in `slack-connector.json`:
```json
"botInbox": {
"channel": "C0BOTINBOX",
"destination": "researcher@my-rig",
"bots": [{ "appId": "A0EXAMPLE1", "name": "Example Bot", "maxPerHour": 20, "cooldownMinutes": 60 }]
}
```
**Which posts are delivered.** A bot's post reaches `destination` when all of these hold:
- it is in `channel`;
- its app is listed in `bots`;
- its app isn't the connector's own app.
**How the sender is identified.** The post's own `bot_id`, resolved to its app with `bots.info` (new feature scope `users:read`). A lookup is cached per bot, and posts arriving together share one lookup. Anything that posts as a named app, including its incoming webhooks, counts as that bot.
- The envelope's `api_app_id` names the app *receiving* the event, the connector's own. So it is used only to rule out OpenRig's own posts, which are never delivered, even if listed.
- A post whose `api_app_id` is missing is dropped.
**What the seat receives.** One row per post:
- source `slack-bot:<app ID>`, tags `slack-bot` and `inbound`, and a summary that starts with the bot's name;
- the body opens with the bot's app, bot, channel and ts, and says the row is data from another program, not an instruction, and not a person;
- the bot's text follows in a fence longer than any backtick run inside it, so it can't close the quote or pass for the provenance.
A bot's post never goes through the person registry, the thread map or a decision reply. So it never answers or closes a decision, even when it replies in that decision's thread.
**Limits.** Each bot may deliver `maxPerHour` posts (default 20) in any rolling hour. The next post cuts the bot off for at least `cooldownMinutes` (default 60), and until the hour has room again, so the hourly maximum holds across a cutoff. The seat gets one notice row, at most one an hour per bot, which says that new posts are dropped while admitted ones still arrive, how the person turns the bot off and applies the change (a daemon restart), and that a later cutoff can extend the time shown. A notice whose row couldn't be created doesn't use up the hour. The counts are in the running connector's memory, so a daemon restart or a rewire (`rig slack disable` then `rig slack enable`) clears them.
**Failures and retries.** A `bots.info` transport, rate-limit or server failure is dead-lettered and retried like a message. Live posts and retries go through one admission: the current inbox channel, the bot's app and its limit. Each post is counted once by its identity (`channel:ts`), so a backlog of failed lookups can't pass the limit, and another copy of an admitted post (a retry, or Slack's `message` and `app_mention` pair) isn't counted again. A post the limit already admitted whose row couldn't be created is kept marked as admitted, saved once that creation fails (on a retry too), and the mark covers every kept copy of that post. A retry pass never rewrites a retained file it can't read, and a live post reads that file again only after something in the daemon has written it (including an earlier connector's handler still finishing after a rewire), not each time; while it can't be read, a live post is kept for retry. So it's delivered later without being counted again or dropped by a cutoff, even after its hour, a rewire or a restart; the marker can't come from Slack's payload. A retained post from a channel that is no longer the bot inbox is dropped and logged. Slack's own refusal (such as `missing_scope`) is logged and the post is not delivered.
**Config errors.** An invalid `botInbox` (for example a bot ID where the app ID belongs) turns only the bot inbox off. `rig slack status` shows a `bot-inbox` line saying why, and loading the config never fails because of it.
**With one app across machines.** When the bot inbox channel belongs to another machine, the hub relays the bot's post there untouched, like any event in that channel, and that machine's own bot inbox delivers it: the relayed envelope carries the receiving app, and the relayed post is checked as a fresh one, with any admission marker in its payload cleared. A relay retry whose file can't be read again is left for the next pass and logged, as the event and click retries are.
## Result changes against main
- **A named bot's post in the bot inbox:** main drops it (`bot_id`); now it lands on `destination`.
- **The app manifest** asks for one more feature scope, `users:read`.
- **Readiness:** `rig slack status` gains a `bot-inbox` line when a `botInbox` block is set.
- **Unchanged:**
- without `botInbox`, every bot post is dropped exactly as before;
- other bots, and named bots outside the inbox channel, are dropped as before;
- people's messages, including people writing in the bot inbox channel, take the existing path.
## Limits
- **Edits and deletions are not delivered.** A bot that writes its answer by editing one message delivers only that message's first version.
- **Attachments on a bot's post** are named in the row, not copied.
- **`rig slack verify`** checks neither `users:read` nor membership in the bot inbox channel.
- **Missed-message recovery** doesn't cover the bot inbox.
- **No reply route yet:** the seat can't answer the bot; the person asks it in Slack.
## Tests
`packages/daemon/test/slack-named-bots.test.ts`, committed before the change. CI is linked in the PR conversation.
- **Through `handleEnvelope` to the queue row:**
- a named bot delivered as the bot;
- a bot reply in a decision's thread doesn't call the decision resolver, while a person's same reply does;
- an unnamed bot dropped and its app ID logged;
- OpenRig's own app dropped even when listed;
- a missing receiving app dropped;
- `bot_profile` and the envelope's app ignored as identity;
- named bots outside the inbox dropped without a lookup;
- no `botInbox` means every bot is dropped;
- edits dropped;
- a `message` and `app_mention` pair landing once, with one lookup;
- a refused lookup not retried;
- a transport failure dead-lettered and landed on retry;
- the cut-off, its single notice and the return after the cooldown;
- an admitted post kept past its hour through a cutoff, and across a rewire with the new hour already used, then delivered;
- an admission granted on a retry that stops before the pass ends, and an unadmitted plus an admitted copy of one post across a rewire;
- an unreadable final re-read after the marked copy is saved, a live post while the retained file can't be read, and one file read for several live posts. These run on an in-memory file adapter, not a daemon kill or a real disk failure;
- a notice that couldn't be created leaving the hour's notice free;
- a person's message in the inbox channel staying on the person path.
- **Also:** the rolling hour, the config checks and readiness row, loading an invalid block, the `users:read` scope, and the `bots.info` request and its retry classification.
## Also changed against the shared-app relay
- A relay retry pass whose final re-read of its retained file fails now leaves that file untouched, through the same shared batch replacement the bot and event passes use, rather than replacing it from a lenient read. Envelopes already sent may be retried and rely on the receiver's dedupe: a possible duplicate, not a loss.
- The named-bot and relay suites are separate; no single test composes a named bot through a two-machine relay, and real Slack across two machines was not run.
An opted-in v2 service bundle can now carry its declared Compose file, show the archived per-service declarations before anything runs, and install into a lasting `--target` through the existing service boot, readiness and seat-launch machinery.
It is off by default. `bundle.services_enabled` (or `OPENRIG_BUNDLE_SERVICES_ENABLED`) gates compose-file packing, the per-service view, target admission and prospective target plans; the daemon reads it on each request. With it off, archive contents, the inspect view and the services refusal are as before, except that the new typed key appears, default `false`, in the config list and help.
With it on:
- Create copies only the declared Compose file, at its relative path, through the existing containment rules; no `.env`, no recursive copy. A missing declared file still creates the archive and shows as unresolved.
- Inspect shows each service's image, declared ports and privileges, environment names (never values), mounts, health and wait targets and down policy, with YAML merge keys resolved, host values left unresolved, and every other declared key listed as "Also declared, not summarized". It describes the archive, not an effective deployment.
- Apply with a target uses the existing materialisation, Compose boot and readiness, and content routing before the first seat launches. The archive path keeps the full service result and the replaced generations, so rollback, recovery retention and inherited project protection behave as on the folder path.
- Plan with a target runs the existing conflict and live-team checks and shows the prospective services without writing target files or starting services; a plan over a running team of the same name is refused, as apply is. Without a target, plan and apply keep the services refusal.
Native archive-to-Vault proof on a real Docker host is not run; coverage uses real archives, routes and SQLite with inert service and seat adapters.
The burn view rated a provider window from the span's first and last readings, so a five-hour window that reset inside the span showed a cross-window rate, often a negative one (for example `five_hour: 5% (-42.5%/h)`).
A provider window is now rated only from the readings that share the latest reading's window, using the daemon's existing `sameReset` (moved unchanged into `provider/usage-windows.ts` and exported; the Claude usage-window code imports it and computes exactly as before). With fewer than two readings in that window there is no rate, and a falling reading within one window still gives none. Samples without `resets_at` match each other, so a series with no reset times is rated as before.
Results that change for the same input (each replaces a wrong value):
- A falling reading across a reset with one reading in the new window: no rate, where the view showed a negative one.
- A falling reading across a reset with two or more readings in the new window: the current window's measured rate, where the view showed a negative cross-window figure.
- A rising reading across a reset with one reading in the new window: no rate, where the view showed a cross-window figure.
- A rising reading across a reset with two or more readings in the new window: the current window's rate, where the view showed a cross-window figure.
- A falling reading within one window: no rate, where the view showed a negative one.
The first and last readings, the reset time and the token figures are unchanged.
One OpenRig Slack app can now serve several machines, routed by channel. A message in a machine's channel reaches that machine's Kernel Operator; a message in a rig's channel reaches that rig's representative or a seat you name; and the answer and later updates come back to the same Slack thread, naming the machine, rig and agent.
Refs #192
## How it works
A Slack app delivers each event to one of its connections, so one machine, the hub, holds the app's Socket Mode connection. Every machine keeps its own gateway: its human registry, thread map, decisions, dedupe and receipts stay local, and it posts its own replies with the app's bot token.
**The hub relays.**
- For a channel another machine owns (`forwardChannels: [{ channel, host }]` in `slack-connector.json`), the hub relays the event, reaction or button click untouched to that machine's daemon, over its http host entry (`rig host pair`), with the token that entry holds.
- A failed relay is kept with its host and retried on the dead-letter cadence, after the hub's own kept events and clicks. After one failed send to a host, its other relays wait for the next pass. A relay that keeps failing is retried with no limit, since dropping it would lose a person's message. `rig slack status` counts it in the backlog.
**Machines without the app-level token.**
- They open no connection. Their connector builds its router anyway, and the new `POST /api/gateway/slack/forwarded` runs a relayed envelope through it.
- That route checks the daemon's auth bearer when one is set, and never relays an envelope again. It trusts its caller for where an event came from: there's no Slack signature on a relayed event, so the sender lookup there isn't proof of a fresh act in Slack.
- `rig slack status` reports `relayedInbound`: that the machine can take relayed events, and how many have arrived.
- These machines drain their own retained events on the socket's five-minute cadence.
**Answers return to the person's thread.**
- A person's top-level message becomes the root of its answer: a thread mapping at its own channel and ts, marked as the person's, and stamped on their row so a rebuild restores it. The row is tagged `slack-thread-root`. A reaction on that message isn't a reaction to an ask.
- When the seat it reached replies with `--reply-to` that row, the answer and later updates post under the person's message, in the channel they used, even if that seat's items otherwise post to another channel. A reply that fails the root's checks (another seat, another person, a closed root) keeps the replying seat's own channel.
- Roots OpenRig posts keep the channel map's remap rule.
- A button click finds its decision by its channel (from the payload or its container) and ts, and whether an update already shares a root is judged for that root: by its channel, or, for an older record that names no channel, by the root the update's post actually landed in (its channel and ts); when that isn't recorded, it counts as shared, as on `main`.
**Channel routing.**
- A person's new message in a rig's channel reaches the rig's representative, its one `delegates_to` root, at the root's current session. A new `inbound` field on a channel-map entry names another seat: `rig slack channel-map set my-rig C0... --inbound lead@my-rig`.
- Everything else goes to `inboundDestination` as before. That covers the default channel (a rig entry there only sends that rig's posts there, unless it names `inbound`), a seat entry's channel, an ambiguous channel, and a rig whose representative can't be found. A seat entry alone doesn't make its channel a seat's inbox.
**Every post names its machine:** `from <seat> on <host id>`, unless the sender's session already carries a host.
## Changed results against main
1. **A reply to a top-level message threads under it.** A person's top-level message now mints a thread root, so a `--reply-to` answer from the seat it reached posts in that thread instead of top-level (`root-missing`). An answer from another seat still posts top-level, now as `root-other-seat`.
2. **Rig channels route to the rig.** A new message in a channel mapped to a rig goes to the rig's representative when one is found, where main sent every new message to `inboundDestination`.
3. **Posts name the machine.** The sender line reads `from <seat> on <host id>` when the daemon has a host id.
4. **`inbound` is a known channel-map field.** It was an unsupported newer field, so the tests that used it as their example now use a hypothetical `policy` field.
5. **Thread roots are keyed by channel and ts.** Migration 115 rebuilds `thread_seat_map` with that key and an `origin` (posted or person); existing roots are kept as posted. Two roots with one ts in different channels no longer share a row.
6. **A fallback names the root's owner first.** When a `--reply-to` reference belongs to another seat or person and also sits in another channel, the recorded reason is `root-other-seat` or `root-other-human`, not `root-other-channel`.
7. **New, only when configured:** `forwardChannels` on a hub, a `forward-channels` readiness line, the relay route, and a connector running with only the bot token, which builds its router without a socket and reports `relayedInbound`.
## Upgrade note
An existing channel-map entry for a rig, on that rig's own channel, now takes the channel's new messages for the rig's representative. A rig entry on the connector's default channel doesn't move that channel's new messages.
## Limits
- **The bot token goes on every participating machine,** and the app-level token on the hub only.
- **Hub coverage:** `rig slack verify` on the hub doesn't check membership in relayed channels, and missed-message recovery covers the hub's default channel only.
- **Handoffs:** an answer from a seat other than the one the message reached still posts top-level.
- **Relayed events** are trusted from the daemon caller, and a relay a machine keeps refusing is retried with no limit.
- **An update posted through the delivery digest** records no thread part, so for an older record that names no channel its root counts as shared, as on `main`.
- **The thread-part lookup** for those older records is an unindexed scan of `thread_part_map`, run only when a decision with its own open root posts.
## What the tests cover, and at what layer
- `slack-shared-foundation-wire.test.ts` runs two machines' wires in one process with Slack faked. The hub's fetch stub hands a relayed envelope straight to the other machine's wire (`acceptForwarded`); it doesn't go through the HTTP route or its middleware, and both machines share one host id there. It covers: a machine channel reaching that machine's Kernel Operator, with the answer and a later update in the person's thread naming the machine; a rig channel reaching its configured seat, a changed seat taking the next message, and the answer returning to the person's channel; the hub's own channel staying on the hub; an unregistered sender still refused; and a machine without its own connection reporting relayed events.
- `slack-shared-foundation.test.ts` covers the receiving route and its bearer separately, with a stub subsystem, plus the representative, resolver and router; minting; the relay, its retry, its loop guard, and messages, reactions and clicks; the forwarder over the registry; the config checks, the machine label and `channel-map set --inbound`; a rig entry on the default channel; and the retry order.
- `slack-reply-to-thread.test.ts` and `slack-thread-routing.test.ts` cover roots per channel, a reaction on a person's own message, a failed reference keeping the replying seat's channel, the person's root stamp and its rebuild, a click that names its channel only in its container, the migration keeping existing roots, and an older record's shared root judged by where its update's post landed, including a retained replay into another channel and a post that never landed. The migration's upgrade test is synthetic (one closed root in an in-memory database).
- Real Slack on two real machines was not run.
A Pi pin such as `lab/y:high` names a model and a thinking level. The model-divergence check compared the whole pin with the recorded model, so it reported a divergence for a seat running exactly what its pin asked for.
The check now reads the pin as Pi does. When the whole pin is a model id that the seat's `models.json` defines, it's read literally first, then with the suffix as a level, which is Pi's order. A missing `models.json` keeps the suffix reading, so an ordinary suffixed pin still matches its record.
When `models.json` can't be read, is malformed or is over the size cap, the record is compared with every reading of the pin first. A record that matches none still gets the existing divergence notice. A record that matches a reading stays pending and warns once that it is unchecked, because the file that decides which reading Pi used can't be read.
Results that change for the same input:
- A suffixed pin whose record matches the pin without its suffix: no divergence notice, where the check reported one.
- With an unreadable `models.json`, a record that matches only the pin without its suffix: pending with an unchecked warning, where the check reported a divergence.
- With an unreadable `models.json`, a record equal to the whole suffixed pin: pending with an unchecked warning, where the check settled silently.
Codex's default sandbox can't reach OpenRig's daemon or tmux, so Codex asks the person to approve OpenRig commands, and sometimes reports a sandbox failure instead of asking.
The `rigs` skill now makes one offer, once, to someone using Codex, right after step 2 starts OpenRig's daemon. It offers to let six coordination command families (`rig ps`, `rig capture`, `rig send`, `rig queue`, `rig status` and `rig whoami`) run without a prompt, through one removable rules file in the person's real Codex folder. The agent shows the actual path first.
- **No, or no answer:** nothing is written.
- **Yes:** the agent follows the existing `applying-a-permission-policy` procedure. It reads the existing rules, keeps any stricter rule, changes no sandbox or approval setting, and creates the file only if it doesn't exist.
- **Undo:** removes only the lines it added.
When the person chose install-only or no team, the daemon isn't started just to make the offer. Both copies of the skill are identical.
Adds a durable record of subscription moves: which home's account was switched, which seats were restarted and verified, what readings the decision used, and what's still unconfirmed. It's a typed, append-only record over the existing event log, with `rig provider moves record|list|show` and matching daemon routes. The reads use the existing `(node_id, type, seq)` index.
It's switched off by default: `provider.subscription_moves`, enabled with `rig config set provider.subscription_moves true` or `OPENRIG_PROVIDER_SUBSCRIPTION_MOVES=true`. With it off, the move routes answer 409 with how to turn it on and write nothing. Existing provider reads, `rig auth switch`, seat restarts and other provider routes are unchanged.
The `openrig-user` skill gains a section for managing subscriptions across a fleet, which applies only when the switch is on and the person has asked. A move restarts each chosen seat with `rig seat restart --keep-conversation --reason`, then checks it with `--verify --expect-account` after a real turn; `--verify` only checks. A record needs evidence for `moved`, `missing` for `unconfirmed` or `unknown`, and at least one reading. A peers-only move is recorded with `seats: []`. A body `operator` is recorded as a claimed identity when no transport identity is present.
Three daemon helpers shortened text with `s.slice(0, n)` plus `…`: `truncateBody` for human-gate notification bodies (280), the slice-story `truncate` (100 and 60), and `truncateExcerpt` for `rig ask` excerpts (240). When an emoji straddled the cut, the result carried a lone UTF-16 surrogate, which shows as a replacement character in notifications, the UI and JSON consumers.
All three now go through one small helper, `sliceWhole(text, end)` in `domain/text-cut.ts`, which takes one unit less when the cut would land between the halves of a surrogate pair. Each result is the same length as before, or one unit shorter. Tests cover the helper and the notification body.
A pod member's `mechanic` (the per-member continuity mechanic) was carried by `RigSpecSchema.normalize` but never emitted by `RigSpecCodec.serialize`, so any parse, normalize and serialize path dropped it. Bundle assembly re-serializes `rig.yaml`, so a bundled rig lost a member-level mechanic.
`serialize` now emits `mechanic` beside `compaction_strategy` when it's set. Specs without one serialize byte-identically.
Result that changes for the same input: a bundled member that declares `mechanic: B` while its profile (or the spec default) declares `A` now restores with `B`, matching a direct launch from the same spec; before, the bundle dropped `B` and the seat used `A`. Unchanged: the export from a running rig still doesn't reconstruct member `mechanic` or `compaction_strategy`, and members with no mechanic behave as before.
The TUI file reader resolves `#anchor` with `headingSlug`, which kept only `[\p{L}\p{N}_\-\s]` and so removed every combining mark. A heading such as `## हिन्दी` (vowel signs and virama are marks) or a decomposed `## Café` got a slug no link uses, and the reader showed "Heading not found".
`headingSlug` now keeps combining marks (`\p{M}`), including variation selectors. github-slugger's rule keeps marks too; parity with the live github.com site is inferred, not checked.
Results that change for the same input:
- A heading containing a combining mark or U+FE0F gets a different slug, so a link written against the old stripped form (for example `#cafe` for a decomposed `Café`, or `#-configuration` for `⚙️ Configuration`) no longer reaches it.
- Duplicate-suffix targets can move. For the two headings `## Cafe` followed by U+0301 and then `## Cafe`, the old slugs were `cafe` and `cafe-1`. They are now `café` (decomposed) and `cafe`, so `#cafe` now reaches the second heading, and `#cafe-1` no longer exists.
- Headings without marks keep their base slug. A missing anchor still opens the file from the start.
`rig watchdog register` reads `--interval-seconds`, `--active-wake-interval-seconds` and `--scan-interval-seconds` with `parseInt(value, 10)`, so a mistyped cadence quietly becomes a different number: `5m` becomes 5, `1.5` becomes 1 and `1e3` becomes 1.
The command keeps that parsing, the numbers it sends, the handling of omitted options, the daemon's answers and the exit codes exactly as before. It now prints one line on stderr when the conversion changes the value's meaning, naming the flag, the value as typed and the seconds it's read as, and says "is not a number" for a value that doesn't parse. Equal forms such as `5`, `05`, `0` and `-5` stay quiet. Stdout, including `--json`, is unchanged, and the warning never claims a job was registered. This doesn't validate cadences strictly or refuse any input.
Tests cover all three flags, quiet equal values, each lossy value with the original number still sent, a required `abc` keeping the daemon's refusal, and clean `--json` output.
In Help, the `palette-move` reducer clamped the selection only at 0. Help and the Enter handler clamped it only for display, so holding down past the last command kept raising the stored selection, and the following up presses did nothing visible until it came back in range.
The reducer now also clamps at the last match for the current query, counted with the same `filterPalette` call Help renders. Context only dims entries and never removes them, so the count is the same in every context. An empty match list keeps the selection at 0. A test overshoots and checks the first up press moves.
When `rig restore <snapshot> --rig <rig>` got a 404, its hint said `List snapshots with: rig snapshot list --rig <rig>`. `rig snapshot list` takes the rig id as a positional argument and has no `--rig` option, so the suggested command failed with an unknown-option error.
The hint now reads `rig snapshot list <rig>`. The existing 404 test extracts the hinted command and runs it through the snapshot command. Only the hint's text changes.
Removal checked for active work before awaiting the tmux kill, then deleted the node without checking again, so queue work written during the kill stayed addressed to a seat that no longer existed.
Removal now rechecks with the same query after the kill and inside the topology transaction. Late work is rerouted through the existing explicit-fallback routing. When there is no usable fallback, the node is kept (stopped, not running), and the result reports the work. A pod shrink can report partial results.
The fallback is revalidated only when work actually needs it: the up-front explicit-fallback check is unchanged, and a removal or shrink with no work involved completes exactly as before, even if an unused fallback has stopped. `removeNode` and the shrink's member removal now share one function.
Results that change for the same input:
- Late work during the kill with no usable fallback: the node is kept and the work reported (409 with the real code), where it used to be removed with the work stranded. This includes late work arriving while the explicit fallback stops running.
- A failure after earlier effects keeps the real failure code and reports every effect already made (rerouted work, removed members, stopped sessions), and no longer says no changes were made. Failed results carry `sessionsKilled` and `nodes`.
A positive `limit` of 2^63 or more reached SQLite's `LIMIT ?` on `/api/queue/overdue` and `/api/telemetry/usage/series` and failed with a 500. Both routes now bound it through the shared `SQL_LIMIT_BOUND` in `query-limit.ts` and answer 400.
On `/overdue` the bound applies after the route's existing normalization, so only the positive value actually sent to SQL is checked. Negatives, zero, non-numbers and values that parse to Infinity still get the default list, as before. The tests compare the returned rows, not only the status, for the preserved cases.
Three readers used `OPENRIG_HOME ?? <default>`, so an exported-but-empty `OPENRIG_HOME` made a home relative to the working directory: the CLI terminal token (`terminalAuthHeaders` read `./terminal-token`), `rig crash-cart` (which read `daemon.json` and the database from the cwd) and the TUI control socket (`<start dir>/run/tui-<id>.sock`). The CLI already selected the daemon endpoint through `getOpenRigHome()`, which treats an empty value as unset, so the token and the endpoint could come from different homes.
The two CLI sites now use `getOpenRigHome()`. The TUI uses `OPENRIG_HOME || ~/.openrig` inline, since it doesn't depend on the CLI package.
Results that change for the same input:
- With `OPENRIG_HOME` unset or empty and `RIGGED_HOME` set, the terminal token and `rig crash-cart` read the `RIGGED_HOME` home (with its deprecation warning), as endpoint selection already did. If that home has no token, no authorization header is sent.
- With neither `OPENRIG_HOME` nor `HOME` set, the token path is the OS home's `.openrig` instead of a path relative to the working directory.
- With an empty `OPENRIG_HOME`, the default home's token is sent to the endpoint already selected (including an explicitly configured remote one), where previously no token, or a file in the working directory, was used.
- The TUI socket moves from `<start dir>/run/` to the home's `run/`. Its access follows that directory's permissions and the umask; this change adds no new permission setting.
- A long home path can now reach the existing socket-path length limit, which a short start directory used to avoid. The same limit already applies when `OPENRIG_HOME` is unset.
Explicit bearer and socket overrides are unchanged. Tests cover the empty-home token and crash-cart paths and the TUI socket path.
The agent-image manifest parser and `AgentImageLibraryService.install` refused any file path containing two consecutive dots (`path.includes("..")`), so ordinary names like `notes/v1..final.md` or `release..notes.md` were rejected with `manifest_invalid`, though they aren't traversal.
Both checks now use the context-pack form, `path.split(/[\\/]/).includes("..")`: only a whole `..` segment, with either separator, is refused, and a leading `/` is still refused. `../x.md`, `a/../b.md`, `a/..` and the backslash form stay refused. Install still validates every path before writing into a freshly created target directory. Tests cover the accepted dotted names, the refused traversal forms and the installed bytes.
A `--filter contextUsage.percent` or `--filter contextUsage.state` on `rig ps` without `--nodes` applies only at the node tier, so at the rig tier it silently did nothing. `rig ps` now prints one notice to stderr in that case: "Notice: --filter <key> is node-level and was not applied at the rig tier; use --nodes to apply it." Stdout and the exit status stay exactly as before, including the JSON envelope, the stopped-rig table and the empty-match message. With `--nodes`, the filter applies as before, and malformed filters keep their existing errors.
Over `--host`, HTTP prints the notice once, and SSH relays the remote's notice without a second local copy. The help text says the filter is node-level. Tests pin main's filtered JSON, text and empty-result output, and the single notice, locally and over `--host`.
Adds a "Wake on new work" section to the `openrig-user` skill for an agent in an attached app that can run background shell commands. It's an opt-in loop that polls `rig queue list --owned --state pending` until pending owned work appears or a deadline passes, then wakes the agent through the app's own background-command completion.
The loop reports what it saw. New owned work prints `NEW WORK` and exits 0. If every poll failed (no seat identity, or the daemon down), it says it couldn't check for work and exits 1, with the next step: run `rig queue list --owned` to see why before starting the wait again. If checking stopped working after an earlier success, it names the time of the first failure since the last successful poll and exits 1. A failure followed by a successful poll stays quiet: the deadline prints "No new work before the deadline" and exits 0 only when the last poll succeeded. The deadline is checked between polls, not as a hard process timeout.
The skill's limits line and identity step are updated, and the skill digests are regenerated. Tests run the `sh` block extracted from the shipped skill against the built CLI and real queue routes: a wake, failed polls then a wake, claimed work, another seat's work, never checked, stopped mid-wait, and recovered. The native desktop run is not covered here.
`formatEventPayload` shortens activity-event previews with `shorten`, which returned `s.slice(0, max - 1) + "…"`. When an emoji straddled the cut, the preview ended in a lone UTF-16 surrogate, which the feed showed as a replacement glyph, for both the 200-character summary and the 40-character key=value preview.
When the last kept unit is a high surrogate, `shorten` now keeps `max - 2` units instead, so the emoji is dropped whole and the result stays within `max`. Output is unchanged whenever no high surrogate sits at the cut. This keeps surrogate pairs whole; a ZWJ sequence or flag can still be cut between code points. A test covers both preview paths.
`rig proof add` writes a proof drop's C1 header with `YAML.stringify`, which folds a long plain scalar onto an indented continuation line. The Scopes view's `fmList` in `scope-view-projection.ts` read only the first line of each list item, so a folded text evidence ref no longer equalled its contract item and showed as unpaired, while Review's `parseC1Header` paired the same drop.
`fmList` now parses the header as YAML first, mapping an array value to strings with blanks dropped, the same list conversion `parseC1Header` uses for `evidences`, and falls back to the old line reader when the header doesn't parse. Two other results change with it. An unquoted numeric-looking ref is now a YAML number, so `- 1.10` reads as "1.1"; quote such a ref to keep its exact text (refs written by `rig proof add` are already quoted). An inline YAML comment after a ref (`- R1 # note`) is no longer part of the ref. This aligns the evidence-list conversion only: the fallback on malformed YAML, the other scalar fields and media handling still differ between the two views. A test covers a folded evidence ref.
A slice whose mini-requirements use `1)` numbering showed no mini-requirements in the Scopes view (`miniRequirements: []`), because the projection in `scope-view-projection.ts` matched only `^\d+\.`. The shared authored-item grammar (`hasAuthoredNumberedItem` in `scaffold-placeholder.ts`, `\d+[.)]`) treats these as authored items, so Review and the scope audit counted them while the Scopes view showed none.
The projection now accepts `N.` and `N)` alike, with continuation lines folded the same way. Every `N.` item reads as before; `N)` items now appear in place. A test rewrites the fixture's items to `1)` and expects the same two items.
`resolveMouseAction` treated every SGR wheel code (`button & 64`) as vertical and read only bit 0 for its direction, so a sideways trackpad swipe or tilt-wheel (66 for wheel left, 67 for wheel right) scrolled the pane under the pointer: 66 scrolled up by 3 and 67 down by 3. On a trackpad, a mostly horizontal gesture jumped the Explorer selection or the content pane.
Wheel codes with bit 1 set now return `null`, and the main loop ignores them, as it does for any non-click. Vertical notches, including ones with shift, meta or ctrl bits, behave as before; no modifier or motion bit sets bit 1. Tests cover 66, 67 and their modified forms in both panes, plus a modified vertical notch that still scrolls.
`GET /api/hosts` probed an `http` host with `net.connect({ host: new URL(host.url).hostname })`. For an IPv6 literal such as `http://[::1]:7433` or a tailnet `http://[fd7a:115c:a1e0::…]:7433`, WHATWG `URL.hostname` keeps the brackets, so `net.connect` failed its lookup and the host showed `status: "unreachable"` even while its daemon was up.
The probe now strips the brackets when they wrap the whole hostname, as the CLI's host probe already does. Hostnames, IPv4 addresses and SSH hosts are probed exactly as before. The new test listens on `::1` and expects the host to read reachable; on a machine without an IPv6 loopback it returns early without checking.
Promoting an inbox entry into queue work used two separate transactions: `InboxHandler.absorb` awaited `queueRepo.create` (which committed the task and notified `queue.created`) and only then updated the inbox entry and wrote `inbox.absorbed`. Overlapping absorbs of one pending entry could each create a task, a failed inbox update left an orphan task behind and let a retry create another, and a `queue.created` subscriber could read the entry as still pending.
Absorption now runs the reread, the queue insertion (through the queue's existing `createWithinTransaction`, with the same validation, transition, provenance and `queue.created` payload as an ordinary create), the inbox update and both events in one `EventBus.withNotifyEnvelope` transaction. Overlapping absorbs now produce one task and later calls return its id; a failed update rolls everything back, and the entry can be absorbed again. The two events and their payloads are unchanged. Two observable differences: a subscriber that reads the entry on `queue.created` now sees it absorbed, and an event emitted by a `queue.created` subscriber is now delivered after `inbox.absorbed` rather than before it. `InboxHandler` now also requires its database, event bus and queue repository to share one database handle, as production already does.
`ChatRepository.searchChat`, which backs the chat half of `rig ask`'s history evidence, put each keyword into `body LIKE '%<kw>%'` with no `ESCAPE`, so `_` and `%` in a keyword acted as SQL wildcards. Keywords are now escaped (`\`, `%`, `_`) and matched with `ESCAPE '\'`.
This narrows some results on purpose. `rate_limit` used to match "rate limit" and "rate-limit" as well as "rate_limit", and now matches only the literal spelling. `100%` used to match any body containing "100" (such as "1000 tokens"), and now matches only "100%". A keyword with a backslash matches as before. Pipe-separated OR keywords and the existing case, order and limit behaviour are unchanged. Through `rig ask`, keywords shorter than three characters are dropped before this search, so a lone `_` or `%` never reaches it there. Tests cover `rate_limit`, `100%`, a backslash and an OR combination.
#1112 kept an emoji whole in `detail.ts`'s row wrapper, but the Scopes (`semantic`, `wrapText`), Pulse (`laneCell`) and Execution (`clip`, `fitSegs`, `wrapWords`) cut sites still sliced by UTF-16 units, so a row cut right after a high surrogate printed a lone surrogate (a replacement glyph) before the `…`, and a hard-wrapped value could split an emoji across two rows.
Adds `sliceWhole` to `detail.ts`, which backs off one unit when a cut would land inside a surrogate pair, and all six sites now call it. Clipped and fitted rows stay within their width. This keeps surrogate pairs whole; a ZWJ sequence or flag can still be cut between code points, as before. At a width of 1, the hard-wrap fallback emits a word's first astral character whole (two UTF-16 units) rather than half of it. Tests cover a Scopes title, a Scopes hard wrap and a Pulse label.
On Compose releases whose `ps --format json` prints a JSON array, a project with no containers prints `[]`. `ComposeServicesAdapter.status()` parsed that to an empty list and then reported "docker compose ps returned unparseable JSON output", so `captureReceipt` threw during snapshot capture for a rig whose services were stopped or not created, and the no-wait-targets boot path returned `compose_status_failed`.
`status()` now reads a payload that is exactly an empty array (whitespace allowed) as `{ ok: true, services: [] }`, the same answer it already gave for empty output from line-format releases. Non-empty arrays, one-object-per-line output, empty output and unparseable output behave as before. A test covers the empty-array case.
The network-default reader test now waits up to two seconds for its orphaned descendant's PID to disappear, instead of requiring that immediately when the bounded process-group cleanup returns. `stopGroup` sends EOF, TERM and KILL to the reader's group with finite waits and then waits for the direct child's exit; the grandchild isn't a child the test runner reaps, so a one-shot `kill(pid, 0)` asked for more than the function promises. A missed cleanup still fails: the fixture's 60-second descendant dies only by the group signals, so it outlasts the poll. Production code is unchanged.
The failed-kill transcript test no longer requires a quiet pane's last-capture timestamp to advance within two seconds. Rotation deliberately skips full reads of unchanged panes, so that timestamp can legitimately stay unchanged while the rotator is still active.
After the simulated kill failure, the test now checks that a marker isn't in the transcript, writes it to its private tmux pane, and waits for the transcript to contain it. Only the rotator writes the transcript file, so this checks that capture actually continued after teardown returned. The successful-kill case and its stopped-rotation assertions are unchanged, and production code is unchanged.
The model-divergence monitor now has an effective-model reader for Pi seats, so a pinned Pi seat is checked. A match settles the seat for its occupant generation. A real mismatch raises the divergence notice, with delivery attempted to the rig's orch seats, the operator and oversight (Slack deferred). Anything ambiguous or unavailable stays unknown: the seat stays pending with its usual warning, and nothing is refused.
- **Which session file:** it's read only when the occupant's generation and boot time are known and the node has a canonical pane binding; the runner sidecar is ready, not exited, and carries a launch id; the pane's own process reads as a shell; exactly one process under the pane is the runner, a direct child of that shell, whose command starts in `buildPiRunnerCommand`'s exact order with the adapter's own entry path and state root, with each identity flag once and no `--runtime`; its launch id equals the sidecar's; and the session file resolves under the seat's `sessions` folder.
- **Which record:** the newest determinate assistant record that is the session's own (a fork's inherited entries don't count), stamped after both the occupant's boot and the runner's start, with the starting second excluded. Error and aborted turns are skipped.
- **Pins:** through the existing `canonicalModelPin`. A pin that isn't a complete `provider/model` id, or a record without a provider, stays unknown.
Limits: the check matches the supported managed-launch shape from `ps` command text, not the exact argument list, so it doesn't prove the daemon started the runner. A runner typed by hand at the pane's prompt, or one argument containing the whole expected text, reads the same. Unlisted shells, whitespace in a shell's path and identity-flag text inside a quoted path stay unknown. It compares Pi's recorded model identity, not provider-side model substitution or a later `/model` change after the generation settled.
A Codex seat that forks another conversation (`session_source: fork`) no longer loses its role when Codex records the new child's id late. The launch waits up to `runtime.readiness_timeout_seconds` (30 s by default; main gave up after about 5 s) for a verified child: the pane's foreground process forks the requested parent, and its own native record, matched to its start time, supplies the id. Only then is the id saved and the role sent. If no verified child appears in time, the seat stops at `attention_required` with its startup context kept, and `rig seat continue <seat>` re-verifies the child, saves its id and delivers the role exactly once, never replaying an attempted delivery.
- At launch, a reused PID's older record, a child of a different parent, a background process and a helper with `codex` only in its arguments now leave the context pending, where main saved an id and sent the role.
- A fork child whose record shares its start second is accepted only from the one process instance that logged nothing earlier. A retired instance with earlier history, or two instances first seen in that second, are refused. Residual: a lone retained retired conversation id first seen in the child's start second can still resolve if the child reuses its PID in that second, before the child logs.
- An unreadable log stops new same-second candidates from being added, and an identity answer that arrives after the launch deadline is ignored.
- Unchanged: a seat handover with `--source fork:<seat>` keeps main's capture and pending-id path, and fresh launches, resumes and a fresh `rig seat continue` behave as before.
Builds on Aschenbath's #1158: the recovery design, the fork-process check, the `continue` extension and most of the tests are theirs.
Refs #1158
Refs #317
Co-authored-by: Nash Aschenbath <[email protected]>
Adds `daemon-restart-preserves-team-identity.yaml`, which runs `up`, `ps`, a daemon restart, `ps`, `down` and `ps` through the shipped CLI against the two-seat stub topology. The post-restart read pins that the same rig is still one running team with exactly two running seats, not archived, so a restart that loses the team's row, or changes its seat counts or status, fails there. The `match` form can't reject an extra running row for the same rig name, and the scenario header says so.
The scenario joins the `installed-scenario` CI job as a passing-only case beside `transcript` and `capture`: a `daemon-restart` entry in `PASSING_CASES` in `packages/test-system/ci/result.mjs`, and the same name in `scripts/run-pr-scenarios.sh`'s `PASSING_CASES` and `--case`. It runs in healthy mode only, in the same container and limits, and only a clean PASS of the named scenario counts. Like the other passing-only cases, it has no planted-failure run in CI.
`docs/as-built/test-layers.md` counts the new scenario, names the four scenarios the CI job runs and the three that stay local-runner-only, and marks the `daemon` help-wanted row partial.
Test files and docs only; no product code changes.
An agent in an app with no shell (an MCP client such as Claude Desktop's Chat tab) can now take part in a team: `rig mcp serve` gains five queue tools over the routes `rig queue` already uses.
- `rig_queue_create`, `rig_queue_show`, `rig_queue_list`, `rig_queue_claim` and `rig_queue_update` act as the seat set once in the server's environment (`OPENRIG_SESSION_NAME`); no tool call names a sender, and `owned` is scoped to that seat.
- `rig_queue_show` returns the item with its transitions, so the assignee's closing note is the answer to a delegated task.
- A create that times out names its request id and says to check `show` before retrying with the same id.
- The tool descriptions say what the routes do: `rig_queue_list` without `state` returns open items (`all` adds closed ones), and a `state` filter returns exactly those states; a `rig_queue_update` timed wake is delivered only to a seat with a terminal, so an attached app's item just waits until it's checked.
- The `openrig-user` skill describes attaching once, giving the person the MCP entry without editing their settings, delegating, reading the answer by id, and the current limit that nothing wakes that agent yet.
The original 18 MCP tools are unchanged. A Code-tab agent with a shell keeps the whole `rig` CLI.
A Pi or OMP seat whose agent has exited to a bare shell now reads as an identity mismatch that needs attention, the same as a Claude or Codex seat in that state. Until now it read as `verified`, so the seat looked running and healthy.
- The shared classifier treats a bare shell (bash, -zsh, sh, fish, dash, -csh, tcsh) under a Pi or OMP seat as a mismatch, and both identity writers record `process_identity_mismatch`; ksh and the rest of the known-shell vocabulary are unchanged.
- Downstream, as for any seat in attention: role routing excludes the seat, restore checks and fresh re-checks report attention, a headless `rig start --json` preview exits 1 as it does for any attention candidate, and an explicit archive without `--force` follows the existing soft-archive rule. A clear-attention request while the shell remains is refused as not demonstrably responsive; restore reconciliation's refusal code for these seats becomes `process_lineage_mismatch`.
- The supported Pi and OMP launch shapes normally reach `node` by the time readiness succeeds, but any later check can still see a shell, before launch or after the agent exits.
- Terminal seats, Claude and Codex controls, ambiguous, unreadable and missing panes keep their existing results.
The branch merges main with #1392 and #1398, which change neither the classifier's inputs nor any Pi or OMP verdict.
Getting-started now gives explicit Claude Code and Codex commands for reading the `rigs` skill, pinned to the release tag that matches the installed `rig --version`, so the skill isn't silently taken from main and the agent isn't left unspecified.
- `rig --version` prints the version and a commit, for example `0.6.11 (379f0a0d)`; the tag is `v0.6.11`.
- The skill is in the repository from v0.6.6; an older install uses that tag or upgrades first.
- One Linux prerequisite line links the recorded bubblewrap loopback error to OpenAI's sandbox prerequisites, and leaves AppArmor changes to the machine's owner.
Documentation only: no preflight, configuration or command changes.
The workflow spec scanner now treats a file as changed when either its mtime or its ctime is later than the cached entry, so a spec restored or copied with an older mtime (`cp -p`, `rsync -a`, `tar x`, `touch -d`) is read again instead of keeping a stale cache entry.
- A cache entry newer than both timestamps is used with no read, as before.
- A same-second entry still takes the content-hash comparison.
- Anything newer is read through the cache.
Cost: a metadata-only change (chmod, chown, rename, xattr) moves ctime without changing content, so that file is re-read and re-parsed on each scan until its content changes. Unchanged files keep today's cost.
A watchdog job that is stopped or made terminal while a delivery is in flight now records that attempt in `last_fire_at`, matching its delivery history. On main, the active-state filter skipped the whole update, so `rig watchdog` kept showing the previous fire time for that job.
- The `state = 'active'` condition now guards only `last_evaluation_at`. A retired job keeps its due boundary, schedule, state and retirement reason, and no queue wake is attempted for it.
- The stamp is the attempt's evaluation time, recorded whether the delivery succeeded or failed; it is not a delivery receipt. Delivery history already kept the attempt.
- An active job's update is unchanged, as is the unscheduled branch.
The only visible change is the last fire time shown for a stopped or terminal job. Four tests cover stopped and terminal jobs with successful and failed deliveries. Refs #1435.
Three first-day frictions on the Codex route into OpenRig, fixed in guidance.
- **A false "sign in again":** in a sandbox without network access, such as Codex's default one, `gh auth status` reports a valid login as an invalid token. Step 1 of the `rigs` skill now says to re-run a check that needs the network with network access before telling the person to sign in again or fix anything. The person's sandbox settings are left as they are.
- **Setup skipped:** the install step ran `rig preflight` and `rig doctor`, so `rig setup` and its default Herdr install never happened. It now runs `rig setup --providers codex` (or `claude`, or `both`), first saying what setup does: it checks Node, tmux and the logins, installs tmux (with Homebrew, when present) or the chosen harness (with npm) if either is missing, installs Herdr unless the person declines it (`--no-herdr`), and adds an OpenRig block to `~/.tmux.conf`. It doesn't start the daemon. Then `rig doctor`.
- **Workshop's configurations:** the kernel operator's offer now names the listing's configurations (`recommended`, `all-claude`, `all-codex` and others) and offers the one that fits the providers the person has.
`skills/rigs/SKILL.md` and its packaged copy stay byte-identical (`rigs-global-skill.test.ts`). No code changes.
`rig auth` and the daemon's Codex auth reader now find the same `.codex` folder when `HOME` is unset or empty, instead of one relative to whatever directory they run in.
- When `HOME` is unset or empty, the fallback root is the account's home from the OS account database (`os.userInfo().homedir`), because `os.homedir()` returns a set `HOME` even when it's empty. If no home can be found, the result is as before, with no new error.
- A nonempty `CODEX_HOME` wins, then a nonempty `HOME`, with relative values kept as given.
- The same small change is in the CLI (`packages/cli/src/lib/codex-auth.ts`) and the daemon's metadata reader (`codex-auth-reader.ts`); the daemon doesn't import the CLI.
- The provider service's two display values for the seats' launch environment are unchanged, since they mirror how seats are launched.
Tests cover an unset and an empty `HOME` with a controlled account home, the CLI and the daemon reader resolving the same folder, and explicit `CODEX_HOME` and `HOME` unchanged.
A waiting row keeps its next wake when its blocker changes, or it is handed onward, while an earlier notification is still sending. Before, the old send's completion overwrote the wake-now schedule and recorded the newer blocker evidence as already presented, so that notification never came.
- Delivery completion compares the job's schedule (spec, interval and due boundary) with the snapshot taken before the send, and acknowledges only an unchanged one. A revised schedule stays due, and the next tick sends it.
- `last_fire_at` is still stamped on every fire, so the next send gets a distinct occurrence and the attempt stays in watchdog history.
- Plain reminders and active-wake jobs never get a schedule revision mid-send, so they complete as before.
- Two bookkeeping results change: a job that becomes inactive during its send keeps its previous fire and evaluation stamps and writes no wake receipt, and an attempt whose schedule changed mid-send writes no receipt on the row; history keeps both attempts.
Tests cover a successful send, a failed send and an onward handoff, each with a second send and no repeat after acknowledgement, plus the repository's comparison cases. No schema or setting changes.
The contributor docs now describe the skill workflow #1396 introduced: edit a shipped skill's one in-repo home, run `npm run mirror-skills` to regenerate its other copies and refresh the digests, then run `npm run mirror-skills:check` and commit the result.
- `CONTRIBUTING.md` no longer tells contributors to edit every copy, or says the full apply needs maintainer-only inputs.
- `docs/as-built/test-layers.md` describes what `--check` covers: skill homes, generated copies, layout and digests, including publishability and home/copy consistency.
- `ARCHITECTURE.md`'s "Generated files" sentence names `npm run mirror-skills` as the way the skill-edge digests are refreshed, matching its "A shipped skill" recipe; the digest-only step is gone.
No scripts, skill content, generated files or policy inputs change.
Mission creation with an explicit workspace (`--workspace P` or `OPENRIG_WORK_ROOT=P`) now creates the mission under that workspace, and `rig scope` reads it back from the same place.
- A named workspace that doesn't exist yet is created at that path by the mission's own recursive `mkdir`, after rendering and validation; nothing is written to the configured root. A path that is a file fails with the filesystem's `ENOTDIR`.
- A project's declared, existing `missions.root` (in `project.yaml`) wins over a conventional `missions/` folder for every command that resolves a workspace: slice create, ship, close and move, progress, stage, verified, repair and approve, and the reads ls, show, graph, audit and `rig proof`. `--workspace P/missions` still names a conventional tree directly.
- Unchanged: with no workspace named, the configured root is used; the other commands still fall back to the configured root when the named workspace has no missions tree, as before, and `approve` still refuses that case.
Builds on dajiaohuang's #104, whose fix and tests are the core of this change.
Refs #104
Co-authored-by: dajiaohuang <[email protected]>
Product skills now regenerate from one editable home inside this repository, so a skill fix is made once and the apply can't replace reviewed text with an outside copy.
- Each skill has one home: its spec copy when it has a spec edge, otherwise its plugin copy. `scripts/skill-edge-layout.generated.json` still lists which copies each skill has, and every other copy is generated from the home.
- The apply reads nothing outside the repository. `OPENRIG_SKILL_CANON_ROOT` is no longer needed; when it's set, the script says it's ignored.
- The apply can't erase a home. Every read and write is planned and every home is staged before any output is replaced, an output that overlaps a home is refused, and a home is never written. On an edge that holds homes, an undeclared skill is left in place and listed.
- The sanitization and leak rules now check the home. Generated copies stay byte-identical to it, and each generated file takes its home's mode.
- `--check` compares every generated copy with its home and reports `copy-diverged`, `copy-missing` or `copy-extra`, even after the digests are refreshed.
- `skills/README.md` and `ARCHITECTURE.md` describe the in-repo source.
One content change: `metadata.openrig.transfer_test: pending` is removed from `openrig-software-factory/SKILL.md` (its spec home and `_canonical` copy, with both digests updated). The sanitizer already strips that key from public copies, and nothing in the product reads it.
Directory modes aren't restored from homes; only file modes are.
`rig ask <rig> "…" --wake` now says which targets reach a seat. A seat is `member@rig`, and `member@rig@N` picks a generation (1 is the newest); a value without `@` goes to the runtime unchanged as a session ID or title, so a bare member name isn't resolved within `<rig>`. The old `seat[@gen]|token` placeholder didn't define a seat.
- The help, its examples and two tests (the help text, and a bare title passed through unchanged).
- `docs/as-built/cli-reference.md` declares `--wake <target>` with the same forms.
- The shipped `orienting-to-an-inherited-seat` and `retiring-and-inheriting-a-seat` skills use the same forms; the first now says seat targets resolve from the seat's `sessions` rows, newest first, and that a two-part `x@2` is a seat name, not a generation. Their digests are regenerated.
Routing, refusals and results are unchanged.
A blocked row parked on its owner's own repeating reminder no longer reads as stranded once that reminder has fired, so the parked-owner consumer stops sending the owner a second wake on top of the reminder that is already reaching it.
Parked diagnosis counts such a held row as healthy only when all of this holds, from existing records:
- the row's latest armed wake is an attached watchdog job, and the row hasn't left `blocked` since that arm (a later park with no wake doesn't inherit it);
- the job is an active `periodic-reminder`, not a queue-wait job, targeting the row's current destination;
- the row's fire since this park delivered `ok`;
- the job's latest evaluation (newest by `evaluated_at`, then `history_id`) is a sent delivery with status `ok` to the row's current destination.
Anything else keeps today's result: before the first fire, a stopped, deleted or retargeted job, another policy, a failed, retained, refused or skipped latest evaluation, a delivery to another seat or an unrecorded target, missing history, a one-shot timer, a blocker, or no wake. The held row shows `servedByOwnReminder: true` in `rig parked`. Only parked diagnosis and its consumers change (`rig parked`, the activity parked view, the parked-owner consumer); the park wake status, the waiting views, the stuck sweep and the wake ladder read exactly as before, and no schema, job or cadence changes.
Tradeoff: delivery is not consumption. An owner whose own reminder keeps delivering but who never acts no longer gets the parked-owner wake or the parked-list warning; the reminder itself keeps firing. If the owner sits at an approval prompt, escalation waits until a later reminder evaluation records the refusal or failure, after which the existing consumer and ladder run; that delay is read from source, not measured, and depends on scan and wake cadence. A benign skip (for example a busy target) as the latest evaluation withholds the exemption until the next delivered fire.
The tmux-control test "drops a client once the reply at the head of its line is overdue, and starts a new one for the next command" failed intermittently in CI: its 50 ms reply timeout met `vi.waitFor`'s default 50 ms polling, so a reply sometimes arrived just after the product correctly judged it overdue, and the read fell through to the spawned fallback.
The test now passes the exec's existing injected clock (`options.now`) and moves it itself: it holds still while writing to the first client, moves forward by the timeout to make that client's head overdue, and holds still for the next client. The real watch timer still has to fire for the drop. Every assertion is unchanged, no fake timers are used, and production source is untouched.
After `rig down` stops a rig, the printed restore hint is now `rig up '<name>' --existing`, with the name shell-quoted as one argument.
- The old `rig up <name>` didn't restore a stopped rig whose name also matches a library spec, such as Workshop: `rig up` refuses that as ambiguous and itself suggests `--existing`. With no library match, both forms restore the same rig.
- The line now matches `rig down`'s own help and the restore-check hint.
- A name that isn't unique still prints `rig restore <snapshot> --rig <id>`, as before.
Keep watching a Claude resume that is still starting.
- When a restored Claude seat hasn't drawn its screen at the end of the unchanged 3.2 s quick window and the boot is still in progress, the Claude adapter keeps polling for the rest of `runtime.readiness_timeout_seconds` (30 s by default, 1 to 600), as the Codex adapter does.
- Claude's screen appears: the seat is `resumed`. It doesn't: the seat ends `attention_required` ("Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept"), with the pane kept and the attempted resume token recorded as an attempt, not certified. It is never `resumed` because a process is alive.
- Only a boot in progress extends the wait. A real gate (login, trust, MCP approval, unproven auto-mode identity), a missing conversation and a resume-selection prompt keep their results. A different occupant (another conversation's Claude, a stable Codex, or a ready-looking screen whose lineage failed the check) keeps today's `failed` result, checked at the window's end, about once a second during the longer wait, and once more at its end.
- At settings of 1, 2 or 3 s no extra polls fit, but an unresolved boot still ends `attention_required` rather than the ordinary token-losing failure.
- With provider-prompt auto-driving on (off by default), a trust prompt during the longer wait gets Enter only when the occupant check shows this launch's own Claude.
- With #1417 on main, a screen showing only Claude's "Not logged in · Run /login" footer is no longer a login gate; during a slow resume it now waits the readiness bound like any boot in progress and, if unresolved, ends `attention_required` with the attempted token kept, where main ends "timed out". A current login refusal still stops early and fails as before.
What it costs:
- The bound is the existing readiness setting, so raising it for slow fresh launches also lengthens this wait. It counts polls from the adapter's verification, not from the launch, so read it as about the setting, not a wall-clock promise (about 31 s a seat at the default in the author's live run).
- Restore starts seats one after another, so the cost adds up per seat that never starts Claude (about 95 s for three at the default, measured by the author; an illustration, not a limit).
- `rig up` and `rig launch` wait 120 s for the daemon, so a few stuck seats can pass it. A client timeout with exit 1 is not a rollback: check `rig ps` before running the command again. The two commands word that timeout differently.
- The later readiness wait (`waitForReady`) runs only after the adapter succeeds; its first check runs straight away, so it adds no backoff sleep when that first check reads ready.
- During the longer wait, the shell and occupant checks run about once a second (about 80 process listings over a 30 s bound), plus the quick window's two tmux reads per extra poll.
Not in this change: Claude's screen recognition (#86 stays open); the older `adapters/claude-resume.ts`; any other runtime.
For #1351.
Refresh the contributor roadmap for people looking for something to help with.
- Where to look: links the roadmap board (what's being worked on, by release and theme) and the repository's contribute page (issues labelled `good first issue`).
- How to help, step 1: comment on an item's issue to say you're working on it and coordinate with others; a maintainer can assign you to make that visible. The tie rule for equivalent pull requests is unchanged.
- More agent apps: a short note replaces the list of specific apps; the design discussion is #43.
- Removed the "merged stage in the project view" item, since #402 is closed.
Docs only.
Run a community agent app in a seat from a small data preset.
- A rig member's `runtime` can name an app installed as one YAML file, `$OPENRIG_HOME/agent-apps/<name>.yaml` (format, name, command, maintainer, limits). The six built-in runtime names are reserved, and shell syntax in the command (including `!` outside single quotes) makes a preset unusable.
- Only a seat launched through the preset path is treated as a community app. The selection is recorded on that session (`sessions.agent_app`, migration 114); it survives a daemon restart, a snapshot and restore, and the preset's removal, and a different occupant doesn't inherit it. A free-text self-attached seat behaves as before, even after a same-named preset appears.
- `rig agent-app list` and `GET /api/agent-apps` list presets, and `rig ps --nodes` labels the seat with its maintainer and limits. Identity reads unconfirmed. Activity comes only from the screen, and a quiet screen reads unknown, not idle.
- Messages use the existing `rig send` path and its protections. Resume and fork are refused as unsupported, and a restore without `--fresh` starts nothing. A missing or unusable preset is refused at preflight with the existing unsupported-runtime text plus the reason.
- Claude Code, Codex, Pi, OMP, terminal and stub seats are unchanged.
Known limits:
- The web UI still colours a quiet app's seat as idle.
- A multi-line `rig send` reaches an app that reads one line per turn as several inputs.
- Identity stays unconfirmed, not a mismatch, after the app exits to a shell.
- Activity can switch between working and unknown at sampling boundaries.
- Whether the new column exists is cached per database connection; a schema change on a live connection would need a reopen. The daemon migrates before any reader starts.
- Native app runs: not run.
Advance development main to 0.6.11 and carry its release text.
- 0.6.11 is published from release/0.6.11 (tag v0.6.11). This is the release's own version and text commit (af1b61bf, #1253) cherry-picked onto main, equal to it file by file.
- The root, cli, daemon and ui package files and the lockfile read 0.6.11; no dependency, script or lifecycle field changes.
- CHANGELOG.md and docs/reference/whats-new.md equal the published tag's files; the capability page gains 0.6.11's lines and keeps main's later ones.
- No code carries: the release branch's code commits are already on main.
Say which lineage check failed, printing only what the check can vouch for.
- A failed lineage check names the check that failed (for example the recorded against the launched conversation) instead of the pane's foreground label.
- Printed values are only the seat's own record (its first eight characters), the pane id and the foreground command, with control characters replaced by ?. A conversation read from a launch's arguments is described, not printed.
- A process that only names a Claude executable is described as found and unverified, and the Codex launcher chain is described by what was read.
- Every check's accept/refuse result, the equality and rotation checks, and the codes are unchanged; the launch identity check keeps its explanation in the caller's message.
- Refs #1196.
Read only a current Claude login refusal as login_required, not the footer.
- The persistent "Not logged in · Run /login" footer alone no longer selects login_required. Readiness means ready for input: the existing TUI, process and identity checks decide it, not authentication or a successful provider call.
- A current refusal keeps failed/login_required at the same place in the order: the latest response after the last submitted prompt is exactly "Not logged in · Please run /login", "Login expired · Please run /login" or "OAuth token revoked · Please run /login". Older scrollback, a newer reply or prompt, and quoted text don't count, within the limits below.
- A real 401 outside those three strings reads ready for input, with or without the footer.
Known limits:
- A current refusal without the footer now reads login_required; main needed the footer.
- A quoted line exactly at a result row's position reads as a result row.
- Joining a response's short-indented wrapped rows can assemble a refusal from two logical lines, and a footer indented six cells or fewer can join a real refusal and break the match.
- With no input box drawn, a just-submitted /login with no result yet can't be told from a typed one.
- A tool result whose first row is exactly a refusal still counts after a blank row or a continuation indented seven or more cells.
- If a resumed pane redraws an old refusal as its last response, the resume probes read login_required; not verified natively.
- Once a seat's stored startup status is ready, nothing automatic re-checks it; a later refusal shows wherever the pane is read again.
- No captured fixture shows how Claude draws a refusal; an unmarked refusal doesn't count.
Refs #1405.
One code path for a row's delivery ledger; the human registry loaded once per listing.
- Listings and single rows derive the delivery ledger through the same pure functions (`currentDeliveryEpisode`, `ledgerOf`).
- A listing loads the human registry at most once, and reads only each row's latest owner notice and its latest outbound one when that is older.
- A single row (`show`, `deliveryOutcomeFor`, `findUndelivered`, the sweeps) reads its receipts after its episode, as on main.
- Accepted, measured: a listing's waiting view reuses the listing's earlier ledger and owner reads, so it is not a snapshot of the whole call; with a destination change, a newly observed waiting owner can pair with the earlier ledger. Both converge on the next read.
- Accepted, source-derived and not measured: a listing works out every row's episode before any ledger reads the clock, so a legacy gateway-owned row just inside the 120-second window can list never-posted when a later row's registry load crosses it. The next read converges.
- Refs #1384.
Let /api/events clients choose how much history to replay.
- `replay=0` attaches live only; `replay=<n>` (ASCII digits) replays the newest n events, scoped by rig when one is given; `replay=all` replays the whole log.
- The replay is read in pages of 500 events up to the newest event at subscribe, and events that arrive meanwhile are buffered and delivered once, in order, before live mode.
- Any other `replay` value, and any count too large to represent, keeps main's full replay. `Last-Event-ID` is parsed as on main and takes precedence over `replay`.
- Cursor reads and paging now run inside the cleanup that unsubscribes the client.
- Refs #1386.
Drive tmux through one control-mode client, opt-in.
- With `OPENRIG_TMUX_CONTROL=1`, the daemon routes its bounded set of tmux commands through one long-lived `tmux -C` client instead of starting a new tmux client for each command. Without it, the default, the daemon starts clients as before.
- The control client marks its own rows and drops only those from listings, and leaves its own session out of the first listing.
- A line with no end yet is bounded in bytes. The keep-alive check confirms no default-shell ran, on any platform.
- Refs #1382: the issue's single control-mode client, as an opt-in for now.
Load only the command being run, and keep main's locality proof.
- The CLI registers its commands lazily, so a named command loads only its own module. `rig --help` and completion still see every command.
- The `rig` wrapper enables Node's on-disk compile cache through the no-argument `module.enableCompileCache()` (added in Node v22.8.0; the cache itself dates to v22.1.0), so `NODE_COMPILE_CACHE` and `NODE_DISABLE_COMPILE_CACHE` still apply. A cache failure stays a quiet optimisation failure, and older runtimes skip the call.
- Locality and identity keep main's behaviour. `client.ts` and `daemon-lifecycle.ts` differ from main only by lazy imports, so every new client proves its endpoint's locality itself at its first request.
- The change folds in #1381.
Share one CRLF-aware frontmatter boundary across the policy and review readers.
- One small helper finds where a frontmatter block starts and ends, with LF or CRLF line endings. The CLI and daemon permission policy spec parsers, the C1 proof composer, the review gatherer and the Scopes view use it. YAML parsing and schema validation are unchanged, and the CLI and daemon copies stay byte-identical.
- LF input gets exactly the previous result in every reader, including body bytes, offsets and closing lines such as `----` or `---suffix` (the rest of the line goes to the body).
- A valid custom permission policy saved with CRLF now resolves like its LF form. A fresh launch takes its declared posture (`full_bypass`, `auto` or the floor) instead of the advisory floor it got when the file failed to parse. Restore re-derives the declared posture instead of keeping the saved one, in either direction. Unreadable or schema-invalid policies keep the fallback, and none, builtin and absent policies are unchanged.
- `rig policy` resolves a CRLF custom policy instead of reporting it invalid, `rig policy show` no longer calls a CRLF config-surface policy advisory, preflight names the declared posture, and a bundle's access summary shows the declared access.
- CRLF C1 proof headers are read, so their verdict and `evidences:` count, and media references come from the body. CRLF review README headers are read. Scopes view list items, mini-requirements and locked-artifact paths are read. Parsing a header doesn't accept evidence or approve anything; existing audit and judgment rules still apply.
Replaces #1337, #1338, #1339 and #1343.
Load the openrig-core plugin's hooks into Claude seats.
- The daemon projected openrig-core into a folder Claude doesn't load, so its hooks never ran in a Claude seat.
- A Claude seat whose resolved plugin selection includes the shipped openrig-core plugin now starts with `CLAUDE_CODE_PLUGIN_DIRS` pointing at the vendored copy, on fresh launch, restore and handover, including the managed `env -i` launch. A seat that didn't select it launches as before.
- `OPENRIG_SEAT_PROJECT_DIR` carries the seat's launch folder on every launch path. The four hook entry points compare it with Claude's `CLAUDE_PROJECT_DIR` before any post, marker or write, so a nested Claude started in another project doesn't act as the seat.
- The comparison uses `fs.realpathSync.native`, so a folder typed in another letter case on a case-insensitive macOS volume still matches. Linux results are unchanged.
Say how to add a newline when Shift+Enter submits in a Claude Code seat.
- One row in the getting-started table of what can interrupt the OpenRig view: whether Shift+Enter reaches Claude Code as its own key depends on the terminal and on tmux's extended-key support and settings.
- To add a newline anyway, press Ctrl+J, or type `\` and then press Enter. The row links Claude Code's tmux configuration guide and the tmux manual's `extended-keys` and `terminal-features` entries.
- It changes no tmux option, configuration or launch behaviour.
Refs #1394.
Preserve current custody on a stale unclaim.
- Unclaim re-checks the latest session and current binding inside its detach transaction. If a replacement appears during the unguarded tmux cleanup, it returns 409 without changing database custody or emitting a detach event. The message names the session and says OpenRig's tmux metadata may already have been cleared.
- A retry on a seat that is already fully released keeps main's result, including persisting and notifying `session.detached` on every call.
- Unclaim and release run under typing protection through a custody-only lease. The lease grants no typing, input or launch authority.
- A release without `--delete` captures its targets before waiting, releases the unchanged ones, and reports a target that changed through the existing partial result. Already-detached claimed seats stay in `released`.
- A release with `--delete` validates the whole captured set before any effect. Membership and session ids are re-read in its deletion transaction, so a seat added or reclaimed during cleanup keeps the rig.
Guide callers to a verified handoff, and flag self-target closures.
- A row whose closure names an unverified handoff carries command advice: use `rig queue handoff` or `handoff-and-complete`. A local handoff is atomic. Across hosts the successor is created first and the source closed after, and an uncertain result is reconciled by its exact id, since re-running the same handoff is idempotent.
- A malformed `chain_of_record` on a candidate successor is skipped by a `json_valid` guard instead of dropping the advice.
- The stuck sweep reports a dangling closure for an otherwise eligible local row whose closure targets itself.
- The advisory and the sweep share one read-only lineage check, `hasLinkedSuccessor`. Each keeps its own result when the lookup fails.
- The 24-hour window, the existing recorded-handoff and explicit-verification exceptions, row state and write acceptance are unchanged.
Refs #318.
Forward the pane's TERM_PROGRAM into managed Claude seats.
- A managed Claude seat (one launched with a native permission mode) starts Claude through `/usr/bin/env -i`, which forwarded `TERM`, `COLORTERM` and the locale but not `TERM_PROGRAM`. Without it, Claude never asked tmux for extended keys, so Shift+Enter submitted the prompt.
- The launch now also forwards `TERM_PROGRAM` and `TERM_PROGRAM_VERSION`, expanded in the pane shell like the existing terminal list (`${KEY:+"KEY=$KEY"}`): an unset or empty value stays absent.
- `TMUX` and `TMUX_PANE` are deliberately not forwarded. Explicit seat-environment values and reserved names keep their results, and unmanaged Claude and Codex launches are unchanged.
- Shift+Enter inserting a newline also needs `extended-keys` on in the person's tmux configuration, as for any program in tmux.
Refs #1394.
Show a body preview in RECENT for a row with no summary.
- `recent-transitions` entries gain `bodyPreview`, derived at read time: the first non-blank line of the body's first 2,000 characters, cut at 240 code points. It is null when the row has a non-blank summary, an empty body, or no such line. `summary` keeps the authored subject, and no other field changes.
- The TUI's RECENT tab shows the preview for a summary-less row, the rig-focused view included, instead of "no work served".
- The body is read only when the summary is blank by the same rule as JavaScript `trim()`, for at most 20 entries a call. An embedded NUL ends the preview, and a NUL before any text gives null.
Refs #1353.
Name a Claude pane a shell only when an idle shell is proven.
- OpenRig starts Claude through a launch script, so the pane command reads `sh` while Claude starts or runs. Three places decided "returned to a shell" from that label alone.
- `checkReady`: an unproven shell label now reports `awaiting_runtime` ("a launch script may still be starting Claude"). The readiness loop treats both codes alike, so only a timed-out start's wording changes.
- `observeSeat`: the same accurate wording; the state stays `attention_required`, which the TUI uses to offer the pane.
- The restore reconcile precondition keeps `pane_not_usable`, with the new wording for an unproven shell.
- `reconcile-session`: no runtime-drift note when the pane's process tree holds the node's runtime (Claude or Codex).
- A pane that is a proven idle shell still reads "returned to a shell". Each check uses the existing `verifyPaneIdleShell`.
Refs #1351, #1199.
Teach `--summary` as every queue row's subject, and carry the queue rules in the shipped skills.
- `rig queue create`, `handoff` and `handoff-and-complete` help, and the missing-summary warning, describe `--summary` as the subject shown on RECENT, the story and the needs-you view.
- `queue-handoff` gains an "Every queue row" section: `--summary`, the `project:`/`mission:`/`slice:` tags, `handoff-and-complete`, closing what you hold. A handoff keeps the source row's tags unless `--tags` or `--gate` is passed, either of which replaces them, so repeat the scope tags in `--tags` with `--gate`. A repository test pins that rule for both verbs.
- `mission-slice-sop` gains a "Records" section that points to `docs/reference/sdlc-conventions.md` and keeps one rule it lacks: editing judged evidence turns the judgment `unknown`.
- `sdlc-conventions.md` A2 gains a paragraph on `stage:` and `verified:`.
- Existing `rig queue` examples in skills, starter cultures, the factory craft file and the capability map gain `--summary`, and the scope tags for project work.
Text only, apart from the new tests.
A Claude resume reads "returned to shell" only when the process tree shows the pane's shell idle.
- OpenRig starts every Claude seat through a launch script, so tmux names `sh` or `bash` as the pane command while Claude runs. That word alone can't say Claude is gone.
- `verifyResumeLaunch`'s final check now asks the existing `observeClaudeDelivery` with `unknownKeepsIdle: false`: two samples with the same fingerprint must show the pane's shell holding the terminal with nothing under it.
- Otherwise, with Claude or its launch script still under the pane, the resume gets the existing "timed out waiting for Claude to become active" result. The seat is still marked failed; the message and the `retry_fresh` hint change.
- A binding with no pane to observe keeps the old behaviour.
Refs #1351.
Codex's `checkReady` decides a shell pane with the existing idle-shell rule before reporting `returned_to_shell`.
- When the static probe reads `returned_to_shell`, `checkReady` calls `verifyPaneIdleShell`: two matching samples of the pane's process tree, the rule legacy restore uses. A proven idle shell keeps `returned_to_shell`; anything else reports `awaiting_runtime`, saying a launch script may still be starting Codex.
- That covers a shell wrapper still starting Codex, another process in the foreground, and unavailable or changing process evidence, which main reported as `returned_to_shell`.
- Readiness doesn't change: neither code stops a wait, so the startup and successor waits poll on both alike. Only the not-ready code and reason, and a timed-out start's final message, change.
- `session-transport.ts` is unchanged.
Refs #79.
A queue listing builds its rows' delivery ledgers in one pass instead of per row.
- `QueueTransitionLog.ownerNotificationsForQitems` reads every owner-notification transition of the listed rows in one statement. `QueueRepository.deliveryOutcomesFor` reads their receipt notes in a second and loads the human registry at most once. `list()` uses it; `show`, the sweeps and every other caller keep `deliveryOutcomeFor`.
- Both paths run one shared derivation (`currentDeliveryEpisode`, `ledgerOf`); only their reads differ.
- This batches the ledger enrichment only. A full listing still makes other per-row reads.
On a synthetic 2,000-row board (3 calls each), compact listings went from a median of about 2,047 ms to 670 ms and full listings from about 4,657 ms to 3,154 ms. 64 steady-state repository, HTTP and CLI-parser cases match main in complete output. The author measured about 486 ms to 240 ms on a 450-item copy of a real database, with identical JSON.
The page and its ledgers are separate reads, as on main, so neither is a single snapshot. With another SQLite connection committing during a listing, two results can differ from main for that call: a destination move after the page read can leave `posted` where main returns no ledger, and a receipt committed during the registry load can read `never-posted` where main sees it. Both converge on the next read. For items with long histories the batch holds all their historical owner-notification and receipt rows while it derives.
An agent running `rig create --json` now learns why a create failed: the failure JSON carries `detail`, the same string the text output prints.
- `detail` comes from the existing chain: the daemon's `errors` joined with "; ", else `message`, else `error`, else the seat's own error, else `Create failed (HTTP <status>)`.
- It's the key `rig grow --json` already uses, so both commands put the reason in the same place.
- Additive only: no field is removed or renamed, a successful create's JSON has no `detail` and is otherwise unchanged, and the exit codes are unchanged.
Tests: four failure cases check that `detail` equals the printed line and the exit code is 1; one success case checks there's no `detail`. The tests-only commit fails exactly those four cases.
The native silent-change transcript suite runs its 13 cases concurrently, as it was declared.
- With the locked Vitest 3.2.4, `describe.concurrent.skipIf(cond)` returns the base suite, so the concurrency flag was dropped and the cases ran one after another. `describe.skipIf(cond).concurrent` keeps both the skip and concurrency.
- Without native tmux the suite is still skipped.
- Each case uses its own tmux server socket, session name, temporary root and database. Vitest's default cap runs at most five at once.
- Test-only; assertions and timing windows are unchanged.
On CI at this head the file's 13 cases took about 56 s (run 39020941765), against about 205 s serially on an earlier run. Different PRs and runners, so that comparison is indicative only.
A periodic snapshot tick where no refreshed seat stopped or was replaced no longer reads and sorts every snapshot of the rig.
- The scheduler checks for a stopped or replaced running seat first and calls `findLatestRestoreUsable` only when one exists; the tick still skips only when both hold, so the decision is unchanged.
- `findLatestRestoreUsable` only reads, so not calling it changes nothing else. A lookup failure is still treated as "no earlier snapshot" and the tick captures; the first usable snapshot and the partial-teardown case behave as before.
- No index, cache or ranking.
Tests: `periodic-snapshot-scheduler.test.ts` gains a stable-tick case (no restore-source read) and a lookup-failure case that now reaches the failing lookup.
Mission readiness resolves a dependency named by a slice's SPEC frontmatter `id:`, so scaffolded slices whose dependencies use that id no longer read as unknown.
- `readMissionReadiness` keeps main's exact member match first. Only when it finds nothing does it read each active member's declared id (SPEC.md, then README.md, through the existing node-file precedence and the slice indexer's frontmatter parser), once per call, and it uses a member only when exactly one declares the name.
- An ambiguous, missing or unreadable id, or an inactive member, still gives main's `dependency_missing`, and the mission stays unknown.
- A mission whose names all match exactly does no extra reads and gives the same result.
- Changed results: a mission that read unknown because of such a name now computes its state from the slices, and dependency eligibility and `next_up` follow; a cycle reached through a SPEC-id edge now reports `dependency_cycle` instead of an unknown dependency, with the state still unknown; and a newly resolved dependency can surface a deeper issue main never reached.
- Every readiness consumer reads it through this shared reader.
Tests: `mission-readiness-spec-identity.test.ts` (new; the run without the fix fails the three expected cases).
The wake ladder reads closed prompt-refusal alerts once per tick instead of once per open episode.
- On the first episode that reaches the check, one `MATERIALIZED` read collects the `json_each` values of closed alert rows, using the old lookup's predicates (terminal state, `json_valid`, the alert tag as a value) plus a `LIKE` pre-filter that only widens the candidate rows. Each episode then checks `prompt-episode:<key>` against that set.
- Same results: the pre-filter drops no row the old query accepted, set membership matches SQL `value = ?` for every value type `json_each` returns, the episode loop has no await or write, and a read failure surfaces at the same first episode. A tick with no open episode runs no read.
- Cost: one pre-filtered scan per tick with at least one open episode, and a transient per-tick set of the closed alerts' tag values. No migration, index, cache or write.
On a synthetic board with 20 open episodes, closed-alert reads per tick go from 20 to 1 and the tick from about 1.8 s to about 0.35 s (one paired run, not a latency distribution).
Tests: `prompt-blocked-alert.test.ts` (18 outcome cases identical before and after; a new count case fails before the fix).
A Codex seat's printed resume command keeps `--no-daemon` when the seat's last managed launch used it, so pasting the command doesn't start a second Codex app-server under the seat's identity.
- `applied_launch_observations` gains a nullable `daemon_opt_out` (migration 113). A Codex managed launch (fresh, fork, resume, the legacy restore) records `true` when its probe found `--no-daemon` and it added it, `false` when the probe found no such option, and nothing when no probe ran. `readCurrentDaemonOptOuts()` reads every node's choice in one query, with the same latest-tenure, invalidations-excluded rule as `readCurrent`.
- Printed commands: recorded `true` puts `--no-daemon` right after `codex`; recorded `false` prints the previous command byte for byte; nothing recorded prints the previous command plus a guidance note in the node detail ("If `codex --help` lists --no-daemon, add it right after `codex`"). That covers seats launched before this change and successors not started by a managed launch; nothing is inherited from a predecessor.
- Consumers: `rig ps --nodes` `resumeCommand`, the node detail's recovery guidance (token resume, `--last` picker, no-token summary) and the UI that renders them. No probe runs on any read path.
- An older daemon on a migrated database leaves the column NULL and ignores it; a newer daemon reading older rows treats NULL as unrecorded.
Tests: `codex-resume-command-daemon-flag.test.ts` (new; 10 of its 11 cases fail without the fix, and the legacy case passes), and the shared test fixture gains migration 113.
Explicit `rig send --dangerously-interact` now types its payload as keys in every readiness state, across local, fan-out, SSH and HTTP delivery.
- The CLI's empty-message check lets any non-empty explicit payload through, so a lone CR, tab or space is sent; zero length is still refused. ESC was already accepted and now acts as ESC instead of a bracketed paste followed by Enter. Ordinary sends, raw sends without interaction and broadcast keep their trimmed-empty check.
- Transport takes the existing unbracketed `TmuxAdapter` path for explicit input whatever the readiness, and the existing `promptAnswerStaged` check decides any Enter: only a complete answer visibly staged in a recognized Claude Code or Codex input gets one. Multi-line and `--context` payloads are typed with their line feeds and get no Enter, a leading composer shortcut acts as that shortcut, and other runtimes never get an inferred Enter (include CR in the payload to send an Enter key). Native runtime effects, including what a line feed does, were not measured.
- Changed result: if the recipient's binding changes between typing and the Enter, the bytes have been sent and the result is `target_runtime_conflict` with no Enter, where the previous code had already pressed Enter. It is the existing recipient check, reached before the Enter in these states.
- Every explicit send records an audit event with the observed readiness, actor and reason; an audit record is not proof of delivery. A detected prompt keeps the existing reason and audit prerequisites; in other states an unavailable audit adds an advisory to an accepted send. With `--verify`, explicit sends read unchecked, and verify never retries or submits.
Tests: `prompt-answer-delivery.test.ts`, `transport-routes.test.ts`, `send.test.ts` and `cross-host-http.test.ts`, red before the fix and green after.
An agent working in a plain clone of OpenRig now finds its starting map from the README.
- One line in the Community list: the `developing-openrig` skill in `.agents/skills/` (Claude Code: `.claude/skills/`) and CONTRIBUTING.md. Both paths exist.
- A README line rather than a tracked root `AGENTS.md`: Codex and Pi seats write OpenRig's managed block into the working directory's `AGENTS.md`, so a tracked one would leave every contributor clone that runs one of those seats with a modified file.
Text only.
A periodic snapshot taken while `rig down` is stopping a rig no longer outranks the full pre-down snapshot, so a restore picks the complete team.
- After its refresh, the periodic scheduler asks the existing restore-usability selector whether the rig already has a usable restore source, just before the skip decision. When a seat stopped or was replaced during the refresh and one exists (the pre-down written during that refresh counts), the tick skips its capture. The code from that read to the capture is synchronous.
- Any usable snapshot kind enables the skip, so a restore that writes `pre_restore` and replaces seats inside one refresh is also skipped for that tick. The next eligible tick captures; repeated churn can defer it again, there's no cap on consecutive skips, and a crash before a later capture can miss the replacement state.
- A failed lookup counts as no earlier snapshot, so the rig still gets its capture.
- Not covered: a tick that starts after teardown has already stopped some seats can still publish a partial set.
- The first usable replacement capture on a rig with no earlier snapshot is kept.
Tests: `periodic-snapshot-scheduler.test.ts` (a partial-teardown case and a lookup-failure case), and the census test's stub.
A legacy (pod-less) restore no longer rolls back a Claude or Codex resume that is still starting just because tmux names the foreground `sh`.
- `verifyPaneIdleShell` in `native-process-lineage.ts` is the Claude delivery check's idle-shell rule, extracted unchanged (`idleShellRoot`, which `observeClaudeDelivery` now calls): over two samples, the pane's own shell owns the foreground, nothing else is in its group, and it's the same shell both times. A missing, failed or changing listing isn't idle.
- Both legacy resumes (`claude-resume.ts`, `codex-resume.ts`) return `retry_fresh` on a shell label only when that check holds. Otherwise they return `attention_required` with the evidence, and the restore keeps the seat with its pane, binding and token.
- Kept as before: a positive missing conversation, a proven return to the shell, auth and selection attention, exact-token and readiness requirements, and the deliberate fresh choice.
- Changed results: a launch wrapper still starting, or an unknown or changing listing, is now kept for attention instead of rolled back; a real return to the shell that the listing can't prove (a shell without job control) is kept too; and a live Claude on another conversation behind `sh` now gets `attention_required` instead of `retry_fresh`, so the restore no longer kills that live process.
- Cost: up to two process listings once per resume wait, only when the final observation shows a shell.
Tests: `legacy-restore-shell-label.test.ts` (new), and updates to the Claude and Codex resume tests.
Refs #1357.
Automatic compaction no longer acts on a context observation whose seat changed while the poll waited.
- The context monitor compares each seat's eligible-session snapshot (session id, name and status, runtime, resume token, cwd, the binding's id, session, pane, window and update time, the newest occupant generation and the newest startup event) before reading it, and the compaction enforcer checks the same snapshot again inside the existing delivery lease, on both the sampled path and the pending-stage drain. A mismatch skips with `stale_observation`.
- So a reading taken before a stop, replacement, handover, pane change or new startup attempt can't start or advance preparation for whoever holds the seat afterwards, and a stopped or retired seat's queued drain isn't advanced.
- A same-occupant change to a compared field (a resume-token rotation, a cwd, window or startup-sequence change, a binding update) also defers that observation. A later current observation proceeds if its other gates allow; repeated changes can defer it again, and a skipped sample isn't filled in later. Manual initiation is unchanged.
- Cost: up to two extra point queries per seat per poll.
Tests: `context-monitor-compaction-race.test.ts`.
A Codex in-place restart could report `native_unknown` when its resume screen was ready before the native process was visible. The restart now checks the existing strict identity and secondary observations immediately, then resamples within the existing finite timing budget.
- Defaults allow 31 evaluations and 30 yields of 100 ms, through the class's injected timing. It's not a hard three-second deadline: each attempt's process and tmux reads add time, so a restart that never verifies takes a few seconds longer than before.
- Each successful strict attempt still needs two fresh matching process observations and the existing secondary condition from the same attempt; an earlier negative observation never counts as proof.
- Only observation repeats: signals, launch preparation, the resume send and the respawn run once, exceptions keep their finalization, and the direct-native pane hold is restored only after final proof. Shared identity APIs and the secondary's cached-array behaviour are unchanged.
- Each attempt keeps its existing binding and verdict write, so during the window a seat can briefly show attention (and be skipped by workflow role selection) before the final result replaces it.
- Exhaustion remains `native_unknown`; success remains `restarted_account_unverified`, never account verification.
Tests: 19 new cases in `seat-restart-in-place.test.ts` (both restart shapes, delayed and invalid evidence, sample drift, pane joins, secondary failure, verdict writes, one launch and final event, held-pane restoration).
Refs #1196.
A request whose JSON body is literally `null` now goes through the existing central body handling instead of crashing a route with a 500.
- `trackJsonBodyParseErrors` treats a parse result of `null` as a tracked body error, and `jsonBodyErrorHandler` answers it with 400 `{"error":"invalid_json","message":"Request body must be a JSON object, not null."}`. The check is `=== null` only: arrays, scalars, objects, empty and malformed bodies take the same path as before, and every other error mapping is unchanged. Only the synthetic error is marked, so a route's own `TypeError` still reaches the 500 path.
- Routes that read the body bare (`ask`, transport send, capture and broadcast, both spec reviews, and the seat typing-guard and held-message routes) answer 400 `invalid_json` for `null` where they answered 500.
- Routes that catch a body error now treat `null` like an empty or malformed body: their own 400 (activity events, review freeze), their own default (a `null` snapshot POST takes a manual snapshot), or the same result as before where `null` already worked (the gateway, the `rig:<id>` terminal alias, the provider usage refresh, env teardown and the explicit null fallbacks).
- For API consumers, three existing 400s change code or message for a `null` body: activity's Pi usage (`invalid_pi_usage` to `invalid_json`), the provider switch (its object-required message to its invalid-JSON message), and terminal open (`view_required` to `body_invalid`).
Tests: `json-body-error.test.ts`.
The shared test setup now gives compaction packets a fixture-scoped default folder, so no test can write into `/tmp/claude-compaction-restore`, the folder real seats use, by forgetting to set its own.
- `test/hermetic-env.setup.ts`, loaded first by the daemon, TUI and CLI suites, sets `OPENRIG_COMPACTION_OUT_ROOT` to `compaction-packets` under the fixture home, right after `OPENRIG_HOME`. The setup's existing exit and signal cleanup removes the home, so the folder goes with it.
- An explicit per-test root (#1352's runner tests, `vi.stubEnv`) still overrides it, and an ambient value in the environment that runs the suite no longer reaches tests.
- `packages/daemon/test/hermetic-env.test.ts` checks that the default is under the fixture-scoped home and isn't the `/tmp` folder.
Tests only; no product code changes.
`rig queue update <id> --summary "..."` with no state change now corrects a row's operator-visible summary in any state, including a pending human-intent row with no blocker.
- Only the summary changes: state, blocker, owner, body, evidence, tags, claim, wakes and history stay as they were. The correction is recorded as one transition: the supplied note with "(summary updated)" appended, or "summary updated" when there's no note (no doubled marker).
- Malformed summaries (empty, whitespace-only, or not a string) are refused with no write, alone or with a note; `null` alone is refused as before, and `null` with a note stays an ordinary note append, as on main.
- An explicit state together with a summary on a terminal row (`done`, `canceled`, `handed-off`) is refused as on main; every other explicit state write and same-state rule keeps main's result, including the claim gate.
- The check lives in the state-preserving branch of `QueueRepository.update`, before any write; create, handoff and park keep their own summary handling.
- The CLI's `--summary` help says it can be used on its own.
Tests: `queue-summary-update.test.ts`.
The reply hint under a message from an `@external` sender now reads `↩ Reply if needed: rig queue create --destination <sender> --summary "<one line>" --body "..." --verify`, so a reply copied from it gets a subject.
- Changed together in the CLI's `wrapSendBody` (`send.ts`) and the daemon's `wrapPaneEnvelope` (`pane-envelope.ts`), the twin renderers kept byte-identical, with each twin's test updated.
- The non-external `↩ Reply: rig send <sender> "..."` line is unchanged; no code parses the reply line.
Four daemon tests ran the real precompact hook without `OPENRIG_COMPACTION_OUT_ROOT`, so the hook wrote packets into `/tmp/claude-compaction-restore`, the folder real seats' packets use.
- `stub-compaction.test.ts` sets the variable per test with `vi.stubEnv` and clears it after; `stub-runner-compaction-e2e.test.ts`, `stub-runner-determinism.test.ts` and `stub-runner-restore-e2e.test.ts` add it to the runner child's environment.
- Each points at `<its own temp dir>/packets`, which is already removed after each test, and each test that runs the hook asserts the packet's folder is that root, so a dropped assignment fails the test.
- Tests only; no product code changes.
Queue update refusals the caller can fix now answer HTTP 400 instead of 500: `errorResponse` in `routes/queue.ts` maps eleven `QueueRepositoryError` codes to 400 just before the 500 fallback.
- Eleven mapped codes; seven are current HTTP refusals of the update route (`wake_not_admitted`, `wake_ambiguous`, `wake_after_invalid`, `closure_fields_not_admitted`, `blocked_on_not_admitted`, `wake_watchdog_not_live`, `wake_watchdog_target_mismatch`). The other four (`wake_max_invalid`, the two `wake_message_*` codes and `wake_self_blocker`) need fields the public route doesn't forward.
- Six queue-update input classes change the CLI's exit from 2 to 1, in normal and JSON output, with the same body and no extra write. `--wake-after 0s` is still refused locally before any request (exit 1). `rig queue block` reaches the mapping on its relevant wake-option paths.
- `blocker_not_found`, `blocker_not_live` and `blocker_malformed` stay 500 / exit 2, so not every 500 here is a server fault.
- A 400 means correct or refresh the request: a missing, inactive or mismatched watchdog may reflect changed state, not a caller mistake.
- Scripts that branch specifically on exit 2 or a 5xx for these refusals will see the change.
Tests: `queue-client-error-status.test.ts`.
`rig usage top` (and `/usage/top`) now measures token movement and resets within one counter history: the same node, usage source and native session. A new conversation's starting total, or a switch of source on the same node, is no longer counted as burn or as a reset.
What changes for the same samples:
- `tokensDelta` and `tokensPerHour` fall wherever an upward jump across a session or source boundary used to count as burn; the time span is unchanged, so rankings can reorder.
- `resets` counts only drops within one history.
- A seat with at most one in-window sample per history now shows `unknown: insufficient_samples` instead of a rank.
- Samples with no session id share one history per node and source; on a schema without the session columns, only the source split applies.
The key change is in `computeTopBurn` (`usage-series.ts`), the one place these deltas are computed. Tests: `usage-native-session-burn.test.ts` and a wiring case in `usage-samples-wiring.test.ts`. Refs #774.
A blank YAML list item (`- `) in an agent spec, a rig spec or a startup file now gets an ordinary validation error at its path ("`<path>[i]`: must be an object") instead of a TypeError thrown out of the validator to its caller.
- The guarded sites: in `agent-manifest.ts`, resource entries, `imports`, plugin resources, the id bookkeeping and `runtime_resources`; in `rigspec-schema.ts`, `wait_for`, `surfaces.urls`, `surfaces.commands` and `checkpoints`; in `startup-validation.ts`, startup `files`.
- A scalar or list item at those sites was already rejected on main. It now gets the same "must be an object" message, and some follow-on messages are dropped. No input main accepts is refused.
- Each guard is local to its site; there's no new shared validator layer and no new rule for valid input.
- Tests: `blank-yaml-list-item.test.ts`.
The agent startup guide gains a "Codex compaction and sizing" section, and the `compaction_strategy` rows in `rig-spec.md` and `agent-spec.md` point at it.
- OpenRig's managed compaction (`rig compact`, the `policies.claude_compaction.*` policy, and the jobs the `managed-compaction` and `apprentice-handover` strategies create) runs only for Claude Code seats; a Codex seat compacts with Codex's own automatic compaction.
- It shows where to read a model's window in Codex's catalog (`codex debug models`), with the numbers for one model given as Codex 0.162.1's, and how to set `model_auto_compact_token_limit` for seats only: put it in a Codex profile file and name the profile with `codex_config_profile`, which OpenRig passes as `-p <name>` on fresh, fork and resume launches.
- It names the cost trade-off, says not to raise `model_context_window` toward `max_context_window` to avoid compaction, marks its example value as an example, and states no default trigger point, since Codex's reference doesn't give one.
- Codex's behaviour is described as Codex's and version-scoped; nothing about it was newly measured here. Docs only.
`docs/reference/rig-spec.md` said that Pi's agent process "keeps the baseline `HOME`", which reads as if Pi ignored a `HOME` value. The Pi runner copies `HOME` from its own environment into the agent's, and only OMP replaces it, so the sentence now says that Pi's agent process receives the runner's `HOME` and that a spec or one-off `HOME` value that reaches the runner is passed to Pi. The OMP clause and the rest of the list are unchanged. Text only; nothing was newly measured.
`GET /api/telemetry/usage/series` and `rig usage series` now say when `since` or `until` isn't recognised as a date or time. The response gains an optional `advisories` entry for each such bound (since "<value>" was not recognised as a date or time; the existing text comparison against captured_at was used), and the human CLI prints each on stderr as a `note:` line, including when the result is empty.
- The usage query is unchanged: status, rows, order and exit code match main for every bound, and a full timestamp with a zone still uses the existing instant comparison.
- A date or time, or a leading part of one such as `2026-10-14T09` or `2026-10-14`, gets no advisory, and neither does an empty or omitted bound.
- With `--json` the advisory is inside the single payload, and a response without one gains no field.
- It uses the advisory helper `time-bound-advisory.ts` already on main, byte for byte. The helper doesn't range-check a zone offset, so a malformed offset such as `+99:99` gets no advisory; the query is unaffected.
rig.yaml takes an optional `env:` block at the rig level and on each member; a seat gets the rig's values with its member's laid over them, name by name. They apply on first launch, on restore (`rig up <rig-name>`) and to a handover successor, reach Claude Code seats through their cleared environment, and `rig export` writes them back. `rig up <spec> --env KEY=VALUE` (repeatable) sets or overrides a value for that launch only. Names match `^[A-Z_][A-Z0-9_]*$`, `OPENRIG_*` names are reserved, and values must be non-empty. Migration 111 adds `rigs.env_json` and `nodes.env_json`.
- The values are not secret. They are stored in OpenRig's database and rig snapshots and copied by `rig export` and bundles, so a secret written there by mistake travels with the spec, and they are visible to local processes while a seat is created.
- Managed Claude keeps its own `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, and Codex keeps its own executable and `CODEX_HOME`. Pi and OMP agent processes get only the names the runner passes; OMP's gets its seat state directory as `HOME`.
- A one-off value, from `--env` or a valid `envOverride` that the `/api/up` and bundle-install routes hand to a launch, stays with the terminal its launch creates. The launch then takes it back out of the tmux session, so a restore or handover successor starts from the spec. If that cleanup fails, the launch stops with `seat_env_cleanup_failed`; the first pane has already started, so nothing is undone. The launch then removes the session it just created and says whether removal was confirmed; if not, it names the session to check and remove. The message carries no value or tmux text.
- Known edge, from reading the code and not measured: for a managed Claude seat, a one-off name the spec doesn't declare stays on the session's name list, so a later pane in that session that gets a value for that name from the tmux server's own environment can forward it where a launch without the one-off would omit it. The first launch's one-off value itself is not passed on.
- A handover reads and writes no session environment, so it keeps main's result, and seats without `env` launch, restore and hand over as before. Existing `/api/up` and `/api/bundles/install` requests keep main's answers, and an `envOverride` they can't use is ignored as before; the CLI checks `--env` itself and refuses it with a restore or a topology.
`GET /api/bundles/history` and `rig bundle history` now say when `since` isn't recognised as a date or time. The response gains an optional `advisories` entry (since "<value>" was not recognised as a date or time; the existing text comparison against installedAt was used), and the human CLI prints it on stderr as a `Note:` line, including when the result is empty.
- The history filter is unchanged: `since` is still compared as text against `installedAt`, so status, records, order, total and exit code match main for every bound.
- A date or time, or a leading part of one such as `2026-10-14T09` or `2026-10-14`, gets no advisory, and neither does an empty or omitted `since`.
- With `--json` the advisory is inside the single payload, and a response without one gains no field.
- The shared helper `time-bound-advisory.ts` doesn't range-check a zone offset, so a malformed offset such as `+99:99` gets no advisory. Filtering is unaffected.
A queue handoff or handoff-and-complete to a seat on another registered host now records the sender's identity provenance on the source row's closing transition: the same stamp a local handoff records (transport, claimed, relay or origin-unknown). Before, that transition stored no provenance, and two readers treat an unstamped transition as one the daemon wrote itself:
- a later fallback's search for the line its reroute wrote could stop at a crafted close naming the reroute's actor and note, and replace a line of an outcome the user had saved;
- the Slack reply-to readers could take a crafted close claiming the daemon's actor and a reply-choice note as the daemon's own choice.
- This is an interpretation change: a request-authored cross-host close is no longer read as an internal fallback or reply-to record. An accepted handoff's status, destination and source state, successor creation, same-target retry and idempotent re-drive are unchanged, and the forwarded create does not carry the stamp.
- The close's blocker propagation now receives the same provenance a local handoff passes. For a row the close unparks, the wake's outbox entry stores that stamp; nothing reads it and delivery is unchanged. The auto-unpark transition itself stays unstamped, as before.
- Rows stored before this change keep no provenance and are not migrated, so an unstamped close already on disk is still read as before.
- Tests cover both routes with a header and a body-claimed identity, the idempotent re-drive, the fallback sequence, the reply-to readers and archiving. The nine new cases fail without the fix.
The daemon's transcript rotation and structural sweep now read a pane only when it has written since the last read (tmux `window_activity`), when its pane key changed (window and pane size, pane id and pid, history size and bytes, cursor position), when the transcript file changed, or once its last real capture is past a 10 s full-read threshold. Otherwise they keep the transcript and verdict they hold and record a check time. A seat with no activity reading (an older tmux, a failed or slow listing) is read on main's cadence.
- Capture times stay the real capture's. A skip records only a separate check time, which extends freshness but is never shown or stored as a capture time; a failed capture clears it.
- One `list-windows` runs at a time, shared by rotation and the sweep, and it stays owned until its child settles, so a slow listing no longer starts overlapping children.
- Accepted tradeoff: the pane key is advisory. When it misses a change, or a quiet pane's capture starts failing, the stale transcript or verdict stays visible until the next full read: the 10 s threshold plus the next eligible step and actual scheduling and capture delays, where main notices at the next sweep. There is no fixed ceiling. In a bounded real-tmux comparison with about 1 s sampling, a modelled missed key change matched a fresh read after about 8-9 s on this change and about 1 s on main.
- The same delay applies to limit ordering: if a pane's attention prompt was captured before a later at_limit event and the pane has printed nothing since, at_limit stays visible until a new real full capture, where main shows the attention verdict after the next sweep. This happens without any key miss.
- The new silent-change test waits 9 s after a seat's first capture before each operation, so it shows each case agreeing with a fresh read near the forced full read. It does not show that the key detected each operation.
- `#{history_bytes}` adds a small per-listing cost on large histories; reviewers measured it on synthetic panes only. The throughput and latency figures in the PR description are the author's measurements.
`rig queue update <id> --state done|canceled|denied|failed|handed-off --resolution "<text>"` stores the text in the row's `resolution` field in the same write as the close, exactly as sent (multi-line and Unicode included), and `rig queue show` returns it. On main, nothing a user could run wrote a closed row's `resolution`.
- The daemon uses `resolution` only when it is non-blank text on an update that closes an open row. Every other request keeps main's answer and effects: a move to pending, in-progress or blocked, an acknowledged reopen, a note-only update, a repeat close, a close on an already-closed row, blank text and a non-string value.
- If the field already holds text, the new outcome goes after it on its own line. A reopened row keeps its first outcome, and a second close appends its own.
- The CLI checks `--resolution` locally: it needs a closing `--state` and non-blank text, or it prints a usage error and exits 1 without contacting the daemon.
- A later fallback (`rig queue fallback`, `rig remove --fallback`, `rig shrink --fallback`) keeps a stored outcome whole and replaces only the line the reroute itself last wrote. That line is found from the row's own transitions, live and archived, through the per-row index. Only a transition the reroute wrote in-process, with no identity provenance, counts; a note-only update that carries the same actor and note is not taken for it. A field that only fallbacks wrote keeps main's replacement for every reason the route accepts.
- Disclosed cost: a row that was rerouted, then closed and reopened, keeps its earlier fallback line when the field holds more than that line, so a later fallback adds a line instead of replacing it. No text is lost.
- The reroute lookup is synchronous and bounded by the row's own transition history, not by the size of the table.
`frontmatterIntent` in `current-work.ts` now accepts `\r?\n` before the closing `---` and splits the block on `/\r?\n/`. On main the frontmatter block matched, but the `\r` left on each line made the `intent:` pattern fail, so the intent came back null.
- LF input and lone CR characters are unchanged.
- A test covers a CRLF SPEC.md.
Both copies of `logical-checkbox.ts` (CLI and daemon) now split on `/\r?\n/`. On main, the `\r` left on each line of a CRLF file made the checkbox pattern fail, so a CRLF block yielded zero items.
- LF input and lone CR characters are unchanged, and the two copies stay byte-identical.
- The callers that find the checkbox section already tolerated `\r`, so they now get the items.
- Tests cover CRLF in both copies.
`progress-indexer.ts` now splits on `/\r?\n/` instead of `"\n"`. On main, every line of a CRLF `PROGRESS.md` kept its `\r`, so the title, heading and checkbox-row patterns failed and the file read as empty.
- LF files and lone CR characters are unchanged. In a mixed file, the CRLF lines now parse too.
- A test covers a CRLF file.
The ntfy adapter now sends a `Title` that contains any character outside printable ASCII as an RFC 2047 encoded word (`=?UTF-8?B?…?=`), which ntfy decodes. On main, a character above U+00FF made the request fail, so the notification was never delivered, and a Latin-1-only title (for example "Café") arrived as raw bytes that ntfy shows with a replacement character.
- Printable ASCII titles, including an empty one, are sent byte-identical to main.
- Title truncation now counts code points rather than UTF-16 units, so a long title is never cut inside a surrogate pair.
- A tab is encoded and decodes to the same tab. Any other control character, which made main's request fail, is now encoded and delivered as part of the title.
- CR and LF are still replaced with spaces first, so no title can add or split a header.
- A long title is sent as one encoded word, which can exceed RFC 2047's 75-character limit; ntfy decodes it whole. `Click` and `Tags` are unchanged.
`normalizeAgentSpec` now passes `profileRaw ?? {}` to `normalizeProfile`, so a bare profile key (`profiles: default:` with nothing after it, which YAML reads as null) normalizes as an empty profile, the same as `default: {}`. On main that spec validated and then threw `Cannot read properties of null (reading 'uses')` wherever the agent was resolved or bundled.
- `validateAgentSpec` is unchanged, and scalar and list profile values keep main's validation and normalized result.
- Tests cover a bare key, `{}`, a scalar, a list and a populated profile.
A `builtin:terminal` seat no longer stops at `awaiting-decision` on every restore after its first. Restore, the restore plan preview and the pre-restore replay check now read `resume_if_possible` as `relaunch_fresh` (`effectiveRestorePolicy` in `restore-policy.ts`) for an ordinary terminal seat only: its saved and current runtime are both `terminal`, and no recorded session for it carries a nonblank resume token. Such a seat restores `fresh-primed` with no decision asked, and its startup actions run again, whether `resume_if_possible` came from the column default or was set explicitly.
- Every other seat keeps main's result. A seat with a recorded resume token still waits for a decision with no new session when it is absent at capture, and keeps main's resume path, with no startup replay, when its occupant resolves. A seat whose saved and current runtime differ, with no saved startup context, waits and launches nothing in the preview, restore and the replay check alike. The runtime-mismatch and ambiguity stops, `--fresh`, explicit non-resume policies and the absent-seat history check are unchanged.
- A required startup file missing for an ordinary terminal seat now fails pre-restore validation (`required_startup_file_missing`, or `startup_owner_root_missing`) and blocks the whole restore before any launch (`not_attempted`), as listing that seat with `--fresh` already does. On main that seat waited for a decision and the rest of the rig restored.
- Preview only: a tokenless terminal saved with an explicit `relaunch_fresh` or `checkpoint_only` policy now forecasts `freshRequired: false`. The restore itself is unchanged.
- A recorded resume token that is only whitespace now counts as no token for this rule, so such a terminal restores fresh and runs its startup again where main took its resume path. This case is read from source, not measured.
- `rig-spec.md` describes the rule and the runtime, identity and replay prerequisites that still apply.
A queue row tagged `slice:<id>`, where `<id>` is the `id:` in a slice's SPEC.md frontmatter, is now read as that slice in the slice index and the review gatherer, as a row tagged with the slice's directory name already is: it appears on the slice's Queue tab, in its agents band and attention, and counts for its derived phase.
- An id resolves only when exactly one slice folder under the indexer's roots declares it (the same mission and name under two roots counts once) and no slice folder has that name. An id claimed by two slices, for example a copied SPEC, resolves nowhere, and a tag that is a directory name keeps meaning that directory.
- The id-to-directory map is built with the slice listing and refreshed when the listing's cache expires or is invalidated, so a changed `id:` or a moved slice can take until then to show.
- `docs/reference/project-workspace.md` says either form resolves.
Changed results for existing data:
- A row tagged only with a slice's unique id now counts for that slice. Before, it counted for no slice, and a human-gate row tagged that way appeared in neither the slice's nor the mission's attention.
- The first such row makes its mission use the typed-row rule, as a directory-tagged row already does: slices with no typed rows stop showing rows they matched only through the mission id or the default rail, and the tagged slice shows its typed rows instead of fallback rows. Rows matching a slice's own name or an explicit rail still show, and no row is deleted.
- A slice with a locked spec or authored PRD whose only active rows are id-tagged can now read `building`.
The execution view's existing resolver is unchanged and can still pick a different slice when ids collide.
A workflow's `preferred_targets` entry can be written `member@{rig}`. When an instance is bound to a rig (`--rig`, or a registered spec default), the trailing `{rig}` resolves to that rig, so one cached spec routes each bound instance's work to its own rig through later hops, failure and exception packets, ordinary and occurrence-local resume, and a database reopen. The cached spec itself stays literal.
- Only a trailing `@{rig}` is replaced, through one helper shared by binding, the validator and exception readiness. Braces in the member part or a partial rig name stay literal.
- Without a binding, the target stays literal and is handled by the normal resolution and queue admission at the step that selects it. Starts whose entry resolves still start, including ones with `{rig}` only in a later or unused role or a second preferred target, and an explicit entry owner keeps its precedence. An unbound placeholder-only start keeps main's 400 `unknown_destination_rig` with the instance and packet rolled back, and an unresolved later hop fails at that hop with the same 400.
- Unbound `validate` reads a `{rig}` target as `unregistered`, saying it needs a bound rig or a literal rig name. Once bound, the resolved target uses the existing readiness check.
- The slice view shows a bound instance's `{rig}` target as the seat it routed to.
`rig start --last` leaves a rig down when it was stopped on purpose, and says how to bring it back. Before, `--last` restored every non-kernel rig that wasn't running and had a usable snapshot, and `rig down` writes a pre-down snapshot, so a rig taken down on purpose came back at the next `rig start --last`.
- Under `--last` only, each candidate's seats are read from `GET /api/rigs/:id/nodes`. A rig is kept down when at least one seat was launched and every launched seat reports `intentionallyStopped: true` (recorded by `rig down` and `rig seat stop`). The output names it, "stopped on purpose (with rig down or rig seat stop)", with `rig start --rigs <rig>`; `--json` carries `keptDown: [{ rigName, restoreCommand }]`.
- Anything else restores as before: a seat that is plain `exited` without the flag, a seat the boot reconciler marked `detached` after tmux was lost, a mix of flagged and unflagged seats, and a daemon that doesn't report the flag.
- When a rig's seat statuses can't be read, it is still selected for restore, with a `seat_status_unreadable` warning on stderr and in `--json` `warnings`; status and exit code are unchanged.
- `--all`, `--rigs <name>` and the interactive picker are unchanged, and a partly running rig still reports `rig_not_stopped`.
- The `openrig-user` skill describes the deliberate-stop exception and the unreadable-status warning.
On macOS, `os.tmpdir()` under the default `TMPDIR` is in `/var/folders/...`, and `/var` is a symlink to `/private/var`. Three test suites built fixture roots with `mkdtempSync` and compared them with paths the product had already canonicalized, so they failed when run locally on a Mac. They now wrap `mkdtempSync` in `realpathSync`:
- `pod-bundle-assembler.test.ts`: the read and file-mode expectations used the non-canonical root, while the assembler passes the realpath it checked. The explicit symlinked-root case still runs.
- `pi-runner-path-native.test.ts`: the shim compared `$PWD`, which `/bin/sh` sets from the physical directory, with the non-canonical temp path.
- `context-git-shallow.test.ts`: the checkout the product records is canonical (`realpath` of the checkout and of `git rev-parse --show-toplevel`), and the test expected the caller's spelling.
CI already canonicalizes its `TMPDIR` before the package tests, which is why these pass there unchanged. No product code changes.
A workflow instance can carry a project, mission and slice scope, and every packet it creates gets those tags, so workflow packets list on their slice's Queue tab in the Projects view.
- `rig workflow instantiate` and `rig workflow run` take `--project`, `--mission`, `--slice` and `--tags`. The scope is stored on the instance (migration 110 adds a nullable `workflow_instances.scope_json`) and stamped after the system tags on every packet the instance creates. Instances without a scope, including every existing one, keep exactly their old tags.
- `rig workflow instantiate-lifecycle` takes the project and mission from the identity it compiles. A compiled step's packets get the slice that step was compiled from. A step an authored graph names maps to a slice by exact directory name, then by a `metadata.id` exactly one active slice claims; anything else gets project and mission tags only, with an advisory where it's ambiguous. An identity that can't be a tag is left off with an advisory, and the lifecycle still instantiates. Slice metadata the lifecycle doesn't run is read only leniently, for step names.
- `POST /api/workflow/instantiate` reads a `scope` object. A valid one naming a catalogued project is applied. One the route can't apply (a project the catalog doesn't declare, a conflicting, reserved or malformed tag or id, or a `scope` that is null, an array or a string) is ignored, as main ignored the key: the route creates one unscoped instance with main's tags, records nothing the scope named, and lists a "Scope not applied" advisory naming the instance. A failure unrelated to the scope still fails.
- The cost: a project id only the daemon can check, such as a mistyped `--project`, creates unscoped work. `instantiate` and `run` print a warning on stderr that names the instance (also under `--json`, where stdout stays parseable) and say UNSCOPED; nothing retries automatically, so running the command again creates a second instance. What the CLI can check itself (an id of the wrong shape, a reserved or contradicting tag) is a usage error before anything is sent.
- The daemon package exports `./workflow-scope` (the scope normalizer and its error) for the CLI's own checks.
A startup prompt that is still staged after its first Enter now gets up to two guarded retries. Each retry presses Enter only and never re-types; it rechecks the full staged text first, and a changed draft or a refused retry stops it. Each extra retry waits about 200 ms.
The startup check recognises more of Claude's footer lines: a mode bar starting with `⏵⏵` or `⏸` (including one cut short in a narrow pane), the `paste again to expand` hint with or without its effort suffix, and Vim's `-- INSERT --`, alone or ahead of another footer.
The composer's closing rule is now the lowest rule below the input line, and only when a recognised footer follows it. Otherwise the submission reads unverified. So a prompt whose own text contains a rule followed by a footer-like line can't stand in for the composer's frame and read as submitted.
Limitation: a rule-shaped row drawn below a recognised footer (for example a status row) leaves the boundary unverified. A cleared prompt then reads unverified with a warning, and a staged prompt gets the warning without the automatic retry main attempted. Because the submission isn't confirmed, an enabled Claude proof-instruction send is also skipped by the existing guard, with its warning (from reading the source; not measured). How often such a row appears in Claude or a user's status line isn't known. Startup still reports ready, and no new failed-startup state is added.
`rig queue show <id> --full` now shows how a closed row ended. For a row in done, handed-off, canceled, denied or failed, it adds a `closingTransition` block with the state, closure reason, closure target, who closed it, when, and the note. The block is the transition that moved the row into its closed state: a note added afterwards doesn't replace it, and a reopened row has none until it closes again.
Open rows look as before, and every existing field of the queue item response is unchanged. Plain `rig queue show` prints the same fields; for a closed row with a closing block, its `readView` reports `complete: false`, lists `closingTransition` in `omittedFields` with a pointer to `--full`, and counts the block in `fullJsonBytes`.
`rig scope mission create` now writes `metadata.status: planned` in the new mission's `mission.yaml`, for ordinary and release missions alike. Before, it wrote no status.
A `planned` mission stays outside the project's proof rollup, which counts only `active` and `release-candidate` missions, so creating a mission doesn't change a project's readiness. It counts once someone sets its status to `active` or `release-candidate`; accepting its slices alone doesn't bring it in, and nothing changes the status automatically. Because the mission now has a phase, operating-posture reads and mission-scope posture writes for it work, using the existing binding or the human-led default, where before they reported the phase as missing. Missions created before this change keep no status.
The scopes view's PROOF header now says what its count means: `a/t accepted` when a judgment policy is configured and the slice has no readiness issues, green exactly when the daemon reports the slice ready, and `n/m paired` in every other case.
At detail widths of 70 or more, where the labelled header line doesn't fit, STATE, PROOF and LOCKS now stack the way the narrow layout already does instead of the line being cut, so the lock state always shows in full. When the line fits, and below 70, the layout is unchanged.
The Projects view now shows a slice whose work is parked on an outside dependency. A queue row in state blocked whose `blocked_on` is an `external:` gate appears as a waiting lane for its slice, with `waiting: {on}`, instead of nothing. The strip, the slice card and the lane page call it a waiting row with what it waits on, and its `next` line points at that wait; the `assignment` wording stays for in-progress lanes. A waiting lane adds no "activity unconfirmed" entry, the parked row still reads as parked, never stalled, and its wake is untouched. An in-progress lane on the same slice wins, and `lanes_live` doesn't count waiting lanes.
Rows parked on a qitem, a human seat, or a `fold:` or `auth:` gate are not lanes. The slice card also finds a lane that names the slice by its directory (`slice:<dir>`), as the neighbouring lookups already did. Choosing the default mission is unchanged.
`rig policy apply <policy> --seat <seat>` now changes an existing seat's stored permission policy without re-creating the seat. It records the new policy, its provenance and a `node.policy_changed` audit event in one transaction; the running process and the rig spec file are unchanged.
Restore, resume and launches that read the seat's stored state use the stored policy, unless an explicit native selection (`rig seat set-permissions`) takes precedence. `rig up` by a saved rig name uses it; a launch from a spec that declares a policy replaces it with the spec's policy, which later restores then use. `--seat none` sets an explicit floor for that seat and doesn't inherit the rig's policy. Afterwards, a `rig launch --retry-startup-from` file has to carry the new policy.
`--seat` accepts the built-in policies and `none`, and an ambiguous seat name is refused without a change.
When the daemon can't capture a Codex seat's resume token, it now logs why, once per reason, without the token, the thread ID or the exception text. A process-listing failure during claim adoption or a handover capture now records a redacted skip event with reason `process_unreadable`; before, it was swallowed with no event. `rig down` still records and prints its warning when Codex discovery fails, as before.
Daemon shutdown now cancels Codex captures that are still running, so no token is written after the daemon stops. The asynchronous waits in one adoption attempt are bounded at two seconds, so on a slow host adoption can end before its eight tries; synchronous SQLite reads are not bounded by it. Retrying is unchanged: every periodic snapshot tick and every manual null-fill makes one attempt for each Codex seat without a token, and a pending fork ID fills the same way.
A watchdog job that is stopped, ended by its park's completion, or retired with its seat's generation while a scheduler pass is already running no longer delivers. Before, the pass worked from a snapshot of active jobs, so a job stopped after the pass selected it, or while its policy was still running, could still deliver, record a fire and become actionable, and a late result could replace the reason the stop recorded.
The engine now re-reads the job's stored state when it starts evaluating a selected job and again after the job's policy returns. If the job is no longer active, it returns a quiet skip and writes nothing, so the job's recorded state and reason stand, with no late delivery, fire record, history, event, evaluation stamp, actionable change or wake attempt. Active jobs, including ones rescheduled or edited while their policy runs, behave as before. A delivery that has already started still completes as before, and a context-usage transcript binding made before the policy runs is kept.
The queue's recent-activity read (`GET /api/queue/recent-transitions`, which the TUI uses) no longer works out every transition's predecessor across all live and archived queue history and sorts it on each call. It now resolves the scope's items once and walks a new `(ts, transition_id, qitem_id)` index on the live and archive transition tables newest first, stopping at the limit, and looks up each row's predecessor by transition ID.
The results are unchanged: the same rows, order, predecessor rule across live and archived history, limit handling and errors. Only the work per call changes. An independent check compared main and this change on the same databases, including reopened items, timestamp ties, escaped rig names and archived rigs, and found byte-identical responses.
Migration 112 adds the two indexes. An existing install builds them once when it upgrades, and afterwards each new transition and each archived row maintains one more index entry. On synthetic queue databases at two sizes, every timed read ran faster than main: ordinary instance and rig reads about 2 to 7 times faster, a sparse rig's read about 2.4 to 2.7 times faster, and reads that match nothing about 3 times faster. The indexes took 10.5 to 14.2 MB at the smaller size and 21 to 25 MB at twice that. These are synthetic measurements, not figures from a live install.
When a seat's session ended (seat stop, seat clean, `rig down` teardown or a launch rollback), its transcript capture kept running. Now capture stops once the session's latest row is `exited`, and a transcript read never restarts it. Unclaiming a seat whose session is still running keeps its capture, and a re-claim keeps one rotation.
The saved transcript stays readable after the session ends: tail, grep and full return the saved text, and their ingest health reports `degraded` / `capture_stale` at any file age, where main reported `live` / `capture_fresh`. A live seat is unchanged: a recent capture reads `live`, and a stale capture keeps the 503 and its warning. Missing (404), unreadable and empty (503) files keep their status codes, and an ended session's unreadable-file error no longer reports capture as live. Node and pod removal still return not found, and redaction is unchanged.
A seat whose startup readiness check failed for a recoverable reason (a readiness timeout, or a gate cleared after its startup actions were delivered) kept reading as failed, or as needing attention, after it had reached a working prompt. Now a successful `send` or `capture` to that session re-runs the runtime's readiness check, and when it passes the seat becomes ready once, with one `node.startup_ready` event and a completion stamp. The `ContextMonitor` poll is unchanged.
Recovery happens only when the current attempt's `node.startup_failed` event records a readiness-stage failure with no remaining work; a failure without that structured stage stays failed and isn't probed. The check's result is written in one transaction that first confirms the attempt is still the latest, so a newer attempt that starts or fails while the check is out is never overwritten. For a Claude seat, the stored session must match the attempt's requested resume identity, be a verified rotation from it, or follow an explicit fresh launch; otherwise, including a failure event without the requested identity fields, the seat moves to `attention_required` with no completion stamp. `node.startup_failed` now also records the failure stage, whether work remains, and the requested resume token and type. Known limit: a Claude seat whose resume fell back to a fresh launch can be left at `attention_required` rather than `ready`.
An artifact-pool watchdog (`artifact-pool-ready`, `edge-artifact-required`) read an artifact's frontmatter only when the file used LF line endings and had a newline after the closing `---`. A ready artifact saved with CRLF endings, or ending right after the closing `---`, lost its frontmatter, so with `include_statuses: [ready]` it was dropped and the waiting seat was never woken. Now both shapes read like an LF file with the same content; a line that only starts with `---` still doesn't close the block, and the raw bytes passed on are unchanged. One further change, from reading the source rather than measuring it: a frontmatter that is empty (`---` followed at once by `---`) now ends there, as an empty block, and the text after it is body. Before, the next `---` line in the body (a Markdown horizontal rule, for example) was taken as the closer and the body text in between was parsed as metadata. So a pool with no status filter can now include such a file where it was dropped as malformed; a status-filtered pool now excludes it rather than reading a status out of its body; and an edge target scan now keeps it, so its raw text can count toward the target mentioning the source key and suppress a wake that the old drop allowed.
`rig launch` used to need a rig's ID; a name was passed through unresolved and the daemon answered not-found. Now it resolves the handle first, locally and with `--host` or a selected host, on the single, subset, plan and first-start retry routes: an exact ID wins (archived IDs and ID-looking names included), then a unique active name; an ambiguous name stops before anything is sent and lists the candidate IDs; a missing handle, or a lookup that fails or returns an unusable list, sends the raw handle exactly as before. Each launch makes that one lookup first. `rig down --host` uses the same remote lookup, so its results change only for host lists that hold malformed rows: an exact ID or unique active name no longer crashes on an unrelated null row and posts the down for that rig; a missing or archived-only name, or an ambiguous one, with a null row gives the existing error (read from source, not measured); a response that isn't a list gives a readable error and sends nothing; a name carried only by a row without a rig ID is not found and sends nothing; and a row without a rig ID that shares a name still counts toward that name's ambiguity, as before. Local `rig down` and `rig add` are unchanged. The PR also updates the CLI reference, the openrig-user and topology skills and the capability page, and regenerates the generated skill-edge digest.
A Codex `PermissionRequest` hook is now recorded as a pending request, keyed by the hook's `turn_id`, and becomes `needs_input` only when a pane read shows a real approval or selection prompt, so a request that Codex's auto-review resolves within seconds no longer marks the seat as needing a person. An unreadable pane still reaches `needs_input` after the grace period, labelled `permission_request_unverified`, and checks back off after a negative read. Each accepted request bumps a per-seat permission revision; an all-clear from a pane read that began before a newer or repeated request is discarded, so it can't retire that request's alert, while a later fresh clear still passes. The `rig send` guard, prompt answers and the reactive tap treat a pending request accordingly, and a send to a readable, empty Codex composer still goes through. openrig-core moves from 0.1.9 to 0.1.10 in both plugin manifests. Refs #896 (the Codex case; the Claude Code case there stays open).
A launch used to replace a project's `.claude/settings.local.json` or `.mcp.json` with the spec's fragment whenever the existing file wasn't a valid JSON object, silently dropping the owner's content. Now, when the target can't be read, isn't valid JSON, or holds an array, number, string, boolean or null, the file is left byte-for-byte, the fragment entry is reported as `skipped` (never copied to `.claude/extensions`), and the launch result carries one warning naming the file, a fixed reason (no file content) and the fragment that wasn't applied; once the file is fixed, the next fresh launch applies it. A skipped entry doesn't fail the launch, so a target that is write-only, unreadable or a directory no longer stops the start either. Valid objects merge as before; missing, empty and whitespace-only files get the fragment; a malformed source fragment and write failures after a successful read still fail the entry. The context collector also leaves a non-object settings file alone, so the status line isn't installed for that seat.
The coordination-primitive note says which fields answer pane delivery for a queue task. A claimed task that is later closed still reads `claimed` only when its stored result was unconfirmed (`delivered-ack-pending` or `indeterminate:…`); a `verified` or `failed:…` result reads as stored. `rig queue create --verify` prints "Terminal receipt unconfirmed, but the seat has claimed the item (claimed)." only for such an unconfirmed, claimed item, and "A claim shows the seat took the item; it does not prove the body was read." for any claimed item. A CLI test drives a woken, claimed and closed task through the real path. Refs #165 (the deliveryOutcome half).
A consumed tmux launch script now removes itself with `/bin/rm -f -- '<path>' 2>/dev/null || command rm -f -- '<path>' || true`, before the unchanged launch line. Where `/bin/rm` exists, the script is removed by it as before, and no PATH `rm` runs. Where it doesn't, POSIX shells (sh, dash, bash, zsh) remove the script through the receiving shell's PATH `rm`, bypassing aliases and functions, and because the line ends in `|| true`, a sourced shell under `set -e` still reaches the launch command, which main's failed `/bin/rm` stopped. Sourced fish without `/bin/rm` keeps main's result: the script remains, fish prints its unknown-command line, and the launch runs once. The launch runs exactly once with its own exit status; the quoted path, `-f --` and exec mode are unchanged.
When the daemon stops, through the shutdown phase or the early startup-signal handler, running runtime probe groups are ended, and a probe the stop ended rejects with `ProbeStoppedError` whatever its exit code, so it never counts as an answer and a saved verified version row is unchanged. The `rig up` preflight's runtime version checks (Claude, Codex, OMP and Pi) now run through the same bounded probe with its 30 s limit; they had no limit before, so a slow but working check now fails there. A check that hits the limit reports "`<bin> --version` didn't answer within 30 s", with no runtime output, no "not available" and no install hint: a blocking error for Claude, Codex and OMP, and the same text as a warning for Pi, which keeps launching. Other failures keep their wording.
A daemon with no Codex seat recorded (no `nodes` row with runtime `codex`) no longer writes the Codex feature flag, the managed activity-hooks block or the hook trust records into `<CODEX_HOME>/config.toml` at boot, so an isolated daemon that still carries the real `CODEX_HOME` leaves the user's file alone. The writes move to `CodexRuntimeAdapter.ensureManagedConfig()`: startup calls it when Codex seats are recorded, and every Codex launch, resume and fork calls it first, including a legacy no-pod restore through `CodexResumeAdapter`. A failure there warns and doesn't block the launch, as at boot. With `runtime.codex.hooks_enabled` false, every boot still strips OpenRig's managed block, with or without a Codex seat, writing only when the block is present. With hooks enabled and no Codex seat, a block an earlier daemon wrote stays until a Codex seat launches, and a seatless boot no longer runs the `codex --version` probe.
A queue row tagged `merged:<pr>` is a recorded report of a merge. The execution view shows it beside the merged rung (`folded.merge_report`: PR, row, `recorded_at` and whether it's linked) and in the basis text, distinct from ancestry. It changes the merged answer only when the same row also carries `candidate:<sha>` that resolves to the slice's built commit and ancestry answered false (for example a squash merge); then the basis says the merge was reported for that candidate, not verified. No candidate, an unresolvable candidate, an unusable declared integration ref, no repository context or a failed merge-base keep ancestry's answer, and rows carrying `merged:` are not used to choose the built candidate. `recorded_at` is when the row recorded the merge, not when it happened. `docs/reference/sdlc-conventions.md` describes the convention.
The human output of `rig mode effective`, `rig mode defaults`, the `rig mode set` preview and confirmed read-back now marks the seven legacy record fields (`autonomy_scope`, `heartbeat_cadence`, `inspection_depth`, `update_detail`, `escalation_threshold`, `concurrency_limit`, `permission_prompt_posture`) and `expiry_or_stale_rule` as `(stored; not applied)`, with one legend line, since nothing in OpenRig acts on them yet; `rig mode set --help` says the same for their options. `scope` (the binding key) and `evidence_citation` (read into the posture's evidence) stay unmarked, and the served default stale rule on `defaults` reads `(not applied)`. JSON output, `rig mode show`, mode names, values, validation and storage are unchanged. `docs/reference/scoped-operating-posture.md` now says a saved legacy mode and a posture are kept side by side at one target, and lists the marked fields.
When a send targets a tmux pane that is in a mode (copy mode, for example), the transport leaves the mode once and then delivers the message, instead of typing it into the mode where it is lost. A pane still in its mode after one cancel is refused (HTTP 409, `pane_in_mode`), and a pane whose mode can't be read sends as before. Both tmux calls go through the argv command path. A person reading that pane's scrollback is returned to the bottom when a message arrives. Refs #331.
Adds `docs/reference/claude-status-line.md`, linked from the README's Claude notes: why a user-level Claude status line doesn't show in a seat's directory (the seat's `.claude/settings.local.json` takes precedence), that OpenRig keeps a custom nonempty status-line command since 0.6.5 and what is lost without the collector, a POSIX-shell wrapper that feeds the same input to the collector and a custom renderer, and how to check after an upgrade or instance change that collection is still current. Docs only. Refs #1272.
`rig ask` exits 1 when the daemon resolves no rig for the name given (unknown, or ambiguous across more than one rig); its guidance and JSON output are unchanged, so a shell caller can branch on an unresolved target. A found rig with little or no evidence still exits 0, and the `--wake` path is unchanged. `rig ask --help` now lists exit 1 as the daemon not running, or the rig unknown or ambiguous. A script under `set -e` or chained with `&&` that treated an unresolved name as success now stops there.
The shared vitest setup (`test/hermetic-env.setup.ts`) removes the fixture `OPENRIG_HOME` and its sibling user home that a worker created when the worker ends: on a normal exit, and on SIGTERM, SIGINT or SIGHUP, after which it re-raises the signal so the worker still ends by it. Only a home this process created and marked is removed, and a failed removal is ignored. In daemon and CLI runs each worker also gets its own `TMPDIR`, removed with the homes, so tests that make temp directories under `os.tmpdir()` leave nothing behind. The tui keeps the `TMPDIR` it was given (`OPENRIG_TEST_KEEP_TMPDIR=1` in its vitest config), because its socket tests size paths to it. Tests only.
In a named mission's execution view, a queue row with one or more `mission:` tags binds only to the missions those tags name exactly (the mission's directory name or id, ASCII case folded as SQL LIKE does). A row with no `mission:` tag keeps the existing tag-or-body-mention binding, and the default view with no mission named is unchanged. The wave-map lookup uses the same exact-tag rule. A tagged row that used to reach another mission's view through a body mention or another tag no longer contributes there (its lanes, built candidate and merged facts); a row tagged with both missions binds to both. This stops another mission's commit showing as a slice's built candidate when two missions share a slice name.
At startup the daemon logs one advisory to stderr (and so to `daemon.log`) when `OPENRIG_HOME` isn't the account's default `.openrig` but `HOME`, or `CODEX_HOME` (explicit, or derived as `<HOME>/.codex`), resolves outside the isolation root. The line names the variables that leak and their paths and says the daemon is starting anyway. The default layout and a scratch `HOME` carrying its own `.openrig` and `.codex` log nothing. Advisory only: the daemon always starts, and every lookup that can fail is contained.
The root test setup (`test/hermetic-env.setup.ts`, used by the daemon and TUI suites) and `prepareHermeticEnv` put stub `codex` and `claude` executables first on PATH, so tests resolve those names to stubs instead of a developer's real runtimes. The stubs answer `--version` (Codex 0.139.0, Claude 2.1.0) and `--help`. Under `OPENRIG_E2E_REAL_CODEX=1` the stubs are skipped, so the real-Codex end-to-end tests keep using the real binary. Native tests that install their own runtime and pass an explicit launch PATH are unaffected. Tests only; no shipped code changes.
`rig add` and single-node `rig launch` (including `--retry-startup-from`) pass a per-request 120 s timeout to the daemon, the same wait subset launches and `rig up` already use. A slow but successful start now returns its real result instead of "timed out, outcome unknown" after 5 s. A start the daemon fails still reports as soon as it answers, and a hung daemon still ends with the same "outcome unknown" error, after 120 s instead of 5 s, with one write and no retry. `--plan`, `--host`, the subset launch and the client's default for every other request keep their deadlines.
`rig status --json` prints one machine-readable object with five keys: `daemon`, `kernel`, `workspaceRoot`, `rigs` and `cmux`, each read from the same source the text view uses, and `null` for a section that wasn't read. A stopped or stale daemon reports `reachable: false` with the guard message and lifecycle; an unverified one reports `reachable: null`; a running but unhealthy one reports `healthy: false` with its reason. A rig-summary failure keeps exit 1 and adds an `error` field alongside the sections that were read. Without `--json`, the text output and exit codes are unchanged.
The context collector's status-line provisioning skips its write to `.claude/settings.local.json` when the status line already holds the collector's command with `type: "command"`, because that write would reproduce the same parsed settings. The context poll (about every 30 seconds per running Claude seat), startup delivery, claim and self-attach no longer rewrite an unchanged file, and another writer's key order and spacing are kept. A missing, empty, older, stale or differently typed collector line is still installed or refreshed, a user's own status line and an unparseable file are still left untouched, a non-object value is still replaced, and the collector script is still copied on each call. A needed write is still an unlocked read-modify-write. New tests cover the two no-write cases and ten controls, and the restore test now expects no write on an unchanged poll and exactly one write, keeping the selected hooks, when the command is stale.
The `rig mode set` preview (without --confirm) reads a successful daemon answer it can't use as unknown. A 200 counts as known only when `writeTarget` is a non-empty string, `bindings` is an array, and each binding has a string id and a mode in the CLI's existing mode list, which equals the daemon's eight supported names. Otherwise the preview says "currently: UNKNOWN (the daemon's answer was malformed)", and the JSON reports current and displaces as unknown, instead of an empty displacement. A named target with no bindings is still a known absence. The proposal display, exit code 2, no write on preview, confirmed writes and every other command are unchanged.
An OMP seat's restore catch-up, and the refresh after each turn, no longer fail once the session's history passes 1 MiB. Under OMP's protocol v1 every get_entries reply over 1 MiB came back as "RPC response exceeded the transport limit", and the durable cursor stayed where it was. When OMP's ready frame advertises protocol v2, the runner now sends negotiate_protocol before get_state and the catch-up, and a new decoder (omp-rpc-frame.ts, written from OMP's documented contract, docs/rpc.md at v18.5.0, commit 9348320c) reassembles chunked replies and events up to OMP's 64 MiB ceiling. The declared length is checked against the limit before any bytes are kept; a physical line over 1 MiB, a chunk count over 256, bad or non-canonical base64, bad UTF-8, a non-object result, and an interleaved or interrupted sequence (including a null, array, non-JSON or blank line while a sequence is pending) each drop the frame with an explicit error line and leave the cursor in place, and a later valid reply recovers. A refused negotiation stays on v1 with one notice line. Pi, and an OMP that doesn't advertise v2, are unchanged. A reply above 64 MiB still fails, as OMP's own ceiling, and the post-turn refresh now transfers and reassembles the whole history each turn up to that ceiling. Checked against real OMP 18.5.0 with no model requests and no prompt: 0.6.10 and main fail the catch-up with the transport-limit error, and at this head the catch-up reaches the last entry. Not exercised against real OMP: the refresh after a real turn, and whether real agent events carry entry ids. Refs #1275.
When managed compaction is enabled with a threshold the compaction enforcer accepts, OpenRig now adds `idleCompaction: false` to the one `--settings` JSON of every Claude session it launches (fresh, fork, resume and restore; ordinary, kernel, team-default and full-bypass seats), so Claude Code's own idle compaction doesn't compact those sessions outside OpenRig's prepared sequence. The key is merged into the existing settings value, never a second flag, and a launch that had none gets exactly one. "Active" uses the enforcer's own threshold check, and the policy is read at each launch through the existing settings resolver. With the policy off, or for non-Claude runtimes, the launch command is unchanged. A lower-precedence `idleCompaction: true` is overridden for these launches; managed Claude settings still win. `autoCompactEnabled` and `DISABLE_AUTO_COMPACT` are untouched, and no native fallback is claimed. Claude Code before 2.1.290 ignores the key. A policy change reaches a session at its next launch, in both directions. The session-compaction skill and the as-built note describe this.
Each rig-mode target now keeps one saved mode and one operating posture. A write replaces only its own kind, so a posture no longer replaces a saved mode, or the reverse. Migration 109 changes the unique index from (scope, target) to (scope, target, kind), and every stored row keeps its id and record. `rig mode effective` keeps its primary binding and adds the other kind when it also applies; `rig mode show` lists both. `rig mode unset` where both are kept removes the latest set one and names the one still kept, so the other kind can become effective afterwards. `--category` removes exactly one through a new route; a daemon that predates categories doesn't have that route, so it removes nothing and the CLI exits 1. The preview asks the daemon where the write would land and what it would replace, and says the current state is UNKNOWN when that isn't reported: an older daemon, a refused or failed read, or a reply missing its write target or bindings. Mode names, posture canonicalisation and the legacy read and delete paths are unchanged.
The daemon's parked-owner pass, and its open-id read, now list a seat's open queue obligations through a new QueueRepository.listOpenObligations query instead of the full list() projection. It applies the same filter, ordering (ts_created descending) and limit that list() applies for that pass, and returns only the id, state and summary the pass reads, so the pass no longer builds full queue items for every open row. As before, rows created in the same millisecond have no tiebreaker. The Claude self-report status path is unchanged. Part of a follow-up set to #1241 and #1242.
A local subset launch (rig launch <rig> --seats ..., without --plan) now gives the daemon up to 120 seconds to answer, the same deadline rig up uses, instead of the client's 5-second default. A subset launch that took longer than 5 seconds used to end in a timeout error; it now returns the real result. Previews (--plan), single-node launches, remote --host launches and the client default keep their existing deadlines. Past 120 seconds the shared CLI error still reports the outcome as unknown and says to check before retrying; nothing is retried. Tests pin each deadline, one write per launch, and a 6-second response on the non-preview subset path.
Adds an opt-in setting, transport.verify_input_box (env OPENRIG_TRANSPORT_VERIFY_INPUT_BOX), off by default, within the scope agreed in Discussion #1249. With it off, nothing changes: no new tmux call, stage or result field. With it on, a --verify send to a Claude Code or Codex seat, queue wakes and the attention probe included, reads verified only when four things hold: the capture before the paste matches an idle layout recorded from Claude Code 2.1.296 or Codex 0.160.0 and names no other client version; one extra read after the existing 200 ms pause shows exactly the sent text in the input box with its word boundaries kept (runs of whitespace compare as one space, and a row break counts as a space only where the box's text must have whitespace there, otherwise the box reads ambiguous); the capture after Enter shows the box empty; and the pane process and the seat's binding are unchanged. Anything else reads rendered-unconfirmed with ok true, and a new additive inputBox field says which case it was. An audited prompt answer reads no box, so its inputBox reads unchecked and the send stays unconfirmed. The feature only reports: nothing is refused, retried, re-sent or paused, the existing guards and main's default matcher are unchanged, and other runtimes keep today's path. Each extra read has a 500 ms deadline budget. On Claude Code a multi-line text collapses to a paste label, so envelopes and queue wakes to Claude stay unconfirmed; such a wake records delivered-ack-pending, and a pending, unclaimed handoff with no pickup evidence can enter the existing escalation path after queue.wake_unconfirmed_window_minutes without being re-sent. The recorded layouts, their versioned fixtures and how to add one are documented in the as-built transport doc and the fixture README.
keeping-a-watch.md's usage warning now keeps a pinned Codex reading usable when another login shares the account. When a second machine signs into the same Codex account under a different login email, the served answer marks the person's own item's linkage unknown, because another login's item shares its key; the page had treated every unknown linkage as can't-tell, so a usable reading was discarded and a low allowance could go unwarned. For Codex, the one item matching the recorded key and the recorded non-empty email is the person's: its own fresh, selected-window reading whose level is not unknown is usable whether its linkage reads unknown or same, and the operator tells the person the other login's observations are unlinked, from the item's notes. A null key, no recorded email, and no or several matches stay can't-tell, and a changed email is not followed: the person sets the watch again. The freshness and level rules and the identity and configured-seat caveats are unchanged. A new doc-test case runs the page's exact command with the same account under another login, finds the pinned item exactly once with its note, checks it has a usable window, and checks the task body's new line.
keeping-a-watch.md gains "A usage warning", which teaches the operator to set a person's low-allowance watch from the served usage. The check is rig provider status --all-hosts --json, which serves memory only and starts no read; --refresh stays a deliberate step the person asks for. The page covers choosing the allowance, window and threshold; the served fields (source machine and times, remaining versus used, freshness and level with their reasons, resetsAt, a failed refresh per provider beside older evidence, one allowance across machines never summed, configured seat links versus observed seat facts, and linkage); and the decision on each fire. A usable reading is a fresh observation of the chosen window whose level is not unknown: met is the lowest usable reading below the threshold, reported with its machine, observedAt and resetsAt, and can't-tell keeps the page's once-then-quiet rule with its reason. A Codex allowance is matched by key and login email on every check, and machines that disagree are said plainly. An example task body uses placeholders only. A new CLI test runs the page's exact command against loopback daemons, including a Claude host whose seats disagree, and checks that every field path and name the page uses is served. No product code changes.
Adds direct tests for the cross-host usage outcomes that had none. An agreeing duplicate allowance is one entry in rig provider status --all-hosts and the TUI's all-hosts view, never a sum; Codex accounts with the same email but different account ids stay two allowances, in the grouping and through the served route; the CLI's all-hosts view only GETs and starts no read on any host; the CLI's --refresh and a Claude refresh leg succeed when a read takes longer than 5 s; and a bounds test built from the real constants checks that each provider's worst-case read and cleanup fits its refresh leg, and each leg and view fits the CLI's waits. To let that test import the real values, the CLI's 20 s and 75 s cross-host waits move to module scope, the Codex reader's 2 s grace and the TUI's 20 s view wait become named constants, and the Claude reader's grace is exported; values and call sites are unchanged. The test harness's Claude fake gains an optional delay.
Adds System → Cost to the TUI, and the cost command. It reads GET /api/telemetry/usage/cost through the TUI's daemon client and shows each seat's reported cost beside its tokens. Each seat's head line is the same as rig usage cost prints; the details carry kind, source, window, Claude resets and freshness, Pi entry classes (inherited and unknown-origin shown apart, not counted), all four token counts, coverage, and any provider charge in full beside the estimate, never added to it. Positive sub-cent amounts show as <$0.01, Codex shows tokens without dollars, and nothing is summed across seats; the header says it covers this host's daemon only and isn't an invoice. An older daemon (404), a failed re-read after a good one (kept and marked stale), an empty store and a response whose seats aren't cost entries each say so. The page only reads; other pages are unchanged.
Adds System → Usage · all hosts to the TUI, and the usage-hosts command. It draws the daemon's cross-host usage view (GET /api/provider/usage/hosts) as each registered host serves it: each host's status and usage reason, failed refresh legs beside the kept observations, skipped entries and the registry state, one entry per allowance with its linkage and notes, each host's rows through the existing Usage row lines, and seats per host. Nothing is grouped, summed or re-timed in the TUI. The page reads by GET only, with a 20 s wait, and re-reads the memory-only view every 30 s while open; a 5xx or unreachable daemon keeps the previous view marked retained, and a 404 or 409 replaces it. It never starts a refresh. A seat's "differs from" line is drawn whenever it's served, as the CLI prints it, and the shared CLI and TUI parity facts now cover seat accounts. Single-host System → Usage is unchanged.
Adds a per-seat reasoning effort choice. rig seat set-effort saves a seat's explicit choice (migration 104) and --clear removes it; rig seat effort reports the effort in force and where it came from. A seat's latest explicit choice applies to its launches until it is cleared, and clearing returns to the spec or default. A restore reads the live seat's choice by logical id, so a seat removed and grown back keeps its saved choice over an older snapshot, and the restore says when it used the saved choice instead of the snapshot's. An in-place Codex restart still refuses an effort change unless the change comes from the saved choice; after --clear, such a restart can still refuse until the seat's next ordinary launch.
When a seat's current runtime doesn't match the runtime its snapshot was started with, restore now stops that seat before launching anything: "No session was started for this seat. Restore a snapshot whose saved startup runtime matches the seat's current runtime." Missing or empty runtime information alone doesn't trigger the stop, and the earlier resume-token-type refusal is removed. The live runtime is used only for this pre-launch comparison; after launch, restore keeps reading the saved snapshot node as before. rig up --plan resolves a seat's saved node by logical id, so after a seat is removed and grown back the plan reports the same stop as the restore, shows the saved history, and doesn't suggest --fresh where it wouldn't help. For a regrown seat restored from a legacy snapshot with the same runtime and a saved session, the plan now forecasts resuming that session instead of requiring --fresh.
When the packaged Claude context collector or activity relay was read-only, copying the new version over it failed with EACCES, and the seat's context meter read unknown. The daemon now re-seats these assets by copying into an exclusively created sibling staging file and renaming it over the destination, so a read-only asset is replaced and keeps its mode. When the staging file can't be created, for example a writable collector under a directory that isn't writable, it falls back to the in-place copy main already does, so readings and relay updates keep working there as before. That fallback keeps the in-place copy's non-atomic semantics and reports its own error if it fails. A failure after staging exists is not retried in place, and cleanup removes only the staging path this call created. Refs #1250.
With compact output on, rig whoami --json --host <h> trims the peer list and its peersNote names a command for the full list. That command dropped --host, so running it showed the local host's peers. It now keeps --host and the identity, so it reaches the same remote host and identity. The rewrite is bound to the note's own value: the top-level peersNote must appear exactly once in the raw output in its canonical JSON form, only that span is rewritten, and the result must parse back to the original object with only peersNote changed; otherwise the output passes through unchanged. Escaped, nested, duplicated or string-embedded copies are left alone. It composes with the existing forwarding of --full over SSH.
rig down now records the existing deliberate-stop intent (markIntentionallyStopped) together with clearing the binding, instead of clearing the intent when it marks a session exited, so a seat stopped on purpose stays recognisably stopped. After the live kills it also records the intent for the latest exited or detached session of a seat when tmux confirms the session is absent and its binding is missing or unchanged; an unavailable probe, a foreign binding or a newly rebound occupant is left as it was, and a fault in that pass doesn't change the command's result, exit code or output. Display consequences, through the existing rollup: a full down reads stopped instead of recoverable; in a partial down the remaining seats read running (the command still exits 2 with the failed kill in errors[] and the failed seat stays bound and unmarked); an already-detached seat confirmed absent reads exited, its dead attach hint is cleared and the rig's live-agent presence becomes false instead of unknown. restoreLifecycleState and restore selection are unchanged; native resume metadata and explicit recovery remain. Refs #1238.
Test-only fix for a load-sensitive wait in packages/daemon/test/pi-usage-cost.test.ts ("R4-F2 with the runner's real reader"). The test asserted the cost view six setTimeout(0) ticks after a refresh, while the runner's parent read (real async file I/O) can finish later on a loaded machine, so it could see own: 2 instead of own: 3. It now counts the parent reads it starts, waits with the file's bounded eventually helper until each has answered (the broken parent with null) before rewriting the file, and then waits for the stored outcome. The broken-parent state is therefore read from a finished read, and the third refresh starts a fresh read of the valid file. Every assertion, every broken-parent variant (empty, malformed, torn, a bad id) and the real file I/O are kept; no product code changes.
Lets a person ask which usage allowance is running low across their registered machines, in one answer. GET /api/provider/usage/hosts reads each registered http host's existing /api/provider/status over the existing host transport and bearer handling (5 s per host, four at a time, an answer within 15 s with any host not yet heard from listed as such), groups the rows by allowance identity without summing them (same, conflict, unknown or single), and reports every host's status and whether it serves usage windows (served, off, older or unavailable). An explicit refresh asks each machine to read its logins now, one bounded leg per host. Bearers never appear in the answer or the CLI output. Off by default (provider.usage_windows); nothing is stored. Every identity is checked against the keys the CLI reads, so a host serving a malformed identity reads as unavailable while the others are served; a missing reason, detail or note prints plainly. Single-host rig provider output is unchanged. Part of 0.7.0 slice 01; the TUI view follows separately.
Transcript rotation made one tmux capture-pane call per seat on its own timer. It now runs on one shared tick and captures up to 24 seats per tmux call, chained, using the existing capturePanesContent. Each chain carries a per-call random marker that splits the output, so one pane's text can't land in another seat's transcript, and each entry keeps the time its own chunk was read. The tmux argv exec gets a 64 MiB output bound (TMUX_MAX_BUFFER), which only the tmux adapter uses; 24 panes of 1000 lines don't fit exec's 1 MiB default. Recognised failures keep their paths: no server or no reachable socket captures nothing, with no per-pane forks; an oversized output or a session or pane gone since the listing halves the chunk, and a lone failing session is read on its own. Any other chain error, including one where the listing fails too, leaves the chunk to per-pane reads, which is main's result at the cost of the chain and the listing: 26 execs a round for a 24-pane chunk whose chain always fails, where main makes 24. The adaptive idle backoff and live settings are unchanged. Part 2 of 2 for the daemon's tmux polling cost, based on the shell-free tmux exec; refs #161.
A daemon whose codex hangs on --version now still starts. On a start with no cached runtime version, startup used to wait for codex --version and then --help with no time limit before opening its HTTP listener, so a hung codex meant /healthz never answered; rig bootstrap was held the same way. Each --version and --help probe now has a 30 s limit (RUNTIME_PROBE_TIMEOUT_MS). Past it, the probe's whole process group gets TERM, then KILL, then a bounded wait for the reap; the runtime is recorded as an error naming the timeout; and --help isn't tried after a --version timeout. Startup then carries on and listens, answering /healthz after about 33 s under a hung codex instead of never. Bootstrap's summary now reports a runtime that wasn't found separately from one that was found but failed its check. Healthy probes behave and print as before.
rig whoami --host <host> --full, and its alias --verbose, now forward --full to the remote CLI over SSH, so the caller gets the remote's full identity, peers, workspace and context output; before, the flag was accepted locally and dropped from the SSH argument list. A remote older than v0.4.0 has no --full and exits 1 with "unknown option --full" where it previously printed its default output. Nothing else in local or remote whoami output changes. The regression drives the real command with the SSH runner stubbed, with node, session, JSON, bare remote and local controls.
A Pi seat can show OpenRouter's actual charge for its requests beside Pi's model-priced estimate. It's off by default, behind usage.pi_openrouter_charge (env OPENRIG_USAGE_PI_OPENROUTER_CHARGE), works alongside usage.pi_cost_facts, and is read at each Pi launch and resume. Each persisted Pi entry records its request API and response ID; for an openai-completions request the runner looks up that generation at OpenRouter's fixed endpoint, with the key only in the Authorization header, no redirects, a bounded body and a timeout, and checks that the reply names the same generation. The charge sits beside the estimate and is never added to it. Migration 108 stores one outcome per entry, insert-or-ignore, so replay, catch-up and restart never count it twice, and a charged outcome is final. A failed lookup retries within a cap and then reads "charge unavailable", keeping the estimate; a failed post of an outcome retries on its own, and after five unacknowledged posts the runner says storage couldn't be confirmed. A reply field containing the lookup key is never stored or shown. A charge is looked up only when the request's credential is established as the forwarded key: an extension seen at any point, a credential-bearing models.json OpenRouter entry, an unclassifiable credential source, or any unreadable reading keeps every later entry unavailable for that runner process, and entries persisted before the runner's first observation are unavailable. models.json is read as Pi 0.87.1 reads it (line comments and trailing commas). Rows stored before migration 108 gain their correlation fields from a replay that agrees with every stored fact field, and never with a disagreeing value. With the setting never turned on, rig usage cost output is unchanged. The Pi launch notice, which is always active, now reports the environment key for a credential-free provider entry and classifies Pi-style commented models.json files instead of reading them as unknown; a key's presence there is not proof of authentication.
Every tmux call the daemon made on Linux and macOS started two processes, /bin/sh and then tmux. It now runs tmux directly by argv from a cached path, one process per call; Windows already used the argv path. The tmux arguments don't change, and a test checks every adapter call site's argv against the shell's own parsing of the old command string. When the cached tmux can't be started at all (a spawn error, so nothing ran), the call goes once through the existing /bin/sh path and the cache is cleared, so the next call looks tmux up again: a tmux script without #! runs, and a first PATH entry whose interpreter is missing gives the same result as main on that host's shell. A tmux that ran and failed is never run again. OPENRIG_TMUX_SHELL=1 at daemon start keeps every tmux call on the shell, as before. Part 1 of 2 for the daemon's tmux polling cost; refs #161.
Three daemon tests failed on pull requests that never touched them. Tests only: no product code changes, and every existing assertion stays. In session-transport, the wait-for-idle hook test now fakes Date, setTimeout and clearTimeout and injects a sleep that advances them, like the file's two other 1 ms expiry tests, so a slow first observation no longer misses a 1 ms wall-clock deadline; it also asserts two attempts, 1 ms waited and a single sleep(1). It checks that fresh UserPromptSubmit evidence reads as running and then expires; a slow observation finishing late stays covered by the file's real-timer tests. In transcript-capture-native, the fixed 200 ms setup wait becomes a wait of up to 10 s for health to first read live and capture_fresh, then the same 25 s window runs with the same checks, and the timeout goes from 35 s to 45 s. No threshold is widened: the product's own margin inside that window is about 0.8 s, so a lapse on a loaded host still reports something real. In ps-summary-stall-red, the teardown prints the measured worst /healthz time on every run, pass or fail, including when an earlier step throws; N, the probe pacing, the 250 ms budget and every assertion are unchanged, with no retries.
Pi seats reported no cost. With usage.pi_cost_facts on (off by default, read at each launch), the Pi runner now records the model-priced cost estimate from Pi's persisted session entries, using the get_entries requests it already makes, and rig usage cost shows it. Each entry is counted once across replay, catch-up, restart and repeated refreshes: migration 107 stores one row per Pi session and entry id, insert-or-ignore. Live message updates never add to the durable total. All five usage-bearing kinds Pi 0.87.1 records are counted, including usage from errored and aborted turns, and an entry with no reported cost is named rather than counted as zero. Entries a fork copied from its parent session are kept apart as inherited: an entry stamped before the fork header is inherited, and one stamped in the header's exact millisecond is decided by the parent session's entry ids. The parent is read only on such a tie, in the background, opened non-blocking, as a regular file with a size cap enforced on the same handle. Its ids are used only when the read is proven complete: it reaches the end of the file at exactly the size the file had when opened, that size is unchanged afterwards, and every line is a whole, valid entry. A parent that is growing, shrinking or mid-write leaves the tie unknown and out of the total, and a later refresh reads again; when the read completes, the latest batch is reported again so a tie that arrived meanwhile settles. With no readable header, origin is unknown and kept apart. With the switch off, nothing is posted and rig usage cost is byte-identical. Pi versions other than 0.87.1 are unverified.
The usage windows and seat accounts that rig provider status prints weren't visible in the TUI. System now has a Usage page, beside Health, Configuration and Connections, and a usage command. It reads GET /api/provider/status, the same read model the CLI prints, and reads the config browser only when the model carries no usage block, to tell a switched-off daemon from one that doesn't serve usage. It never calls the refresh or switch routes and never reads the logins itself; the page header says how to read them now with rig provider status --refresh. Each usage row shows identity and bucket, the served seats value, and per window the remaining percentage, level and freshness on separate lines with their reasons, reset and observation times as served, a failed refresh beside the kept reading, and unresolved or earlier rows labelled. An absent value shows as ?, never zero, and nothing is deduplicated, matched or re-timed in the client. Seat accounts show as configured (registry or default login) or unknown, with whether a profile is saved shown as unknown when it can't be told, and allowances never linked to seats. A switched-off or older daemon, a 404 and a 503 each say so, a previous answer is kept and marked after a failed re-read, and other sections keep working. The view covers this host's daemon only; it is not fleet-wide and doesn't prove which login a running session uses.
The context line that names a parked gate's workflow, instance and step had no length bound, so a very long hand-written tag could make Slack reject the whole post. Each tag value is now redacted, then cut to 80 characters, then escaped. A longer value keeps its first 32 and last 33 characters around an ellipsis and ends with " #" and the first 12 hex characters of the SHA-256 of the whole redacted value, so labels that differ at the start or end stay visibly distinct, and a collision between labels that differ only in the middle is unlikely, not impossible. A redaction marker is kept whole or left out, never split, and no mention, link or escape syntax is split by the cut. Near the post's size limit the line falls back to the instance alone, then is left out, where the post was refused before. Values of 80 or fewer and generated instance ids are shown whole, as before.
The provider view could say which account an allowance belonged to, but not which account each seat was set up to use, and it called every Claude seat and every Codex seat without a registry row "a seat with no bound account". While provider.usage_windows is on, the read model now carries seatAccounts, one row per live seat, and rig provider status prints a SEATS section. Each row keeps three facts apart. Configured: a registry profile name and its recorded time (whether that profile is saved on this host is true, false, or unknown when the folder can't be read), the daemon's default login home (an empty CLAUDE_CONFIG_DIR resolves per seat, as at launch), or unknown with the exact gap (an unreadable or malformed registry is unknown, not a default login). Observed: for Claude, the seat's own latest status-line samples and when it last reported; a difference from another seat is shown only for current, fresh readings. Allowance: never linked to a seat, with the reason. Stale registry rows, runtime and rig mismatches and shared session names are shown as notes. Only profile names and error codes are read; profile contents never are. With the switch off, output is byte-identical, and existing bindings and usage rows are unchanged.
GET /api/scope/audit?mission=<name> joined the name onto the slices root and audited whatever existed there, so a name like ../outside read and reported on a folder outside the root. The route keeps that same joined target and now answers the existing 404 mission_not_found when the target, lexically normalized, is outside the root, using the existing isPathInsideRoot comparison (path.relative, separator-safe, so a filesystem-root slices root keeps its children). Names that stay inside are unchanged, including a leading-slash name, which still means a folder under the root, and an inner ".." that lands back inside. This is lexical containment, not a symlink rule, and its target rule differs from the scope-approve write route's. The CLI's own local audit is untouched. Tests cover the escape and the preserved names.
A workflow gate parked on a human seat posted the seat's whole agent packet to Slack (the workflow title, instance and step lines, the objective and the continuation command), while the person approving the gate needs the headline and the question. A blocked gate row with a summary now renders as the question: the summary as the headline, the ask, the evidence through the existing link or media path, and one context line naming the workflow, instance and step from the row's own tags, so two open instances of one gate are told apart. Without a summary the packet stays as the fallback. The row and its body are unchanged for the seat that resumes; tag text passes through the same escaping as the rest of the message; other rows render as before. Reported by @tianqizhao0704 in #1013.
The portability report takes file names from git diff headers. With Git's default core.quotePath, a non-ASCII name came out C-quoted ("caf\303\251.md"), and a name with spaces kept Git's trailing tab, so findings pointed at paths that don't exist. The report's diff now runs with -c core.quotePath=false and strips the trailing tab, so UTF-8 names and names with spaces are reported as their real paths. Names Git still quotes (control characters, quotes, backslashes) stay quoted, now without the "+++ " prefix. A native-Git test sets the repository's quotePath both ways and checks the reported locations.
The CLI install validator, the daemon manifest parser and the library's file resolver refused any context pack path containing `..`, so ordinary names such as `notes..md`, `..notes.md` or `versions..docs/v1..v2.md` failed with "must be a relative path inside the pack (no '..' segments ...)". They now refuse a path segment (split on `/` and `\`) that is exactly `..`, which is the rule their error text states and the one the HTTP route, the profile source resolver and the git source already apply. Absolute and leading-slash paths, lexical containment, bundle realpath containment and install's refusal of symlinks are unchanged, and parent, nested, mixed-separator and URL-encoded traversal stay refused. Tests cover the new names through install, parse, scan and resolve, and traversal at all three checks; the package-time generator's test now uses a real `../escape.md` case. Not covered: `rig context get pack/<file>` still refuses a file name that starts with `..` (its pack-reference parsing is unchanged), and Windows was not run natively.
The Claude statusline input already carries `cost.total_cost_usd`; the sidecar now keeps it, and the existing context sample path (sidecar, context store, the 30 s monitor tick, slice 01's `usage_samples` series) carries it with the native session id. A new `rig usage cost` view (and its telemetry route) reports per seat: for Claude, a cumulative client-side estimate bound to that native session since its last reset, labelled with its source, window and coverage ("not an invoice"), with resets counted and each restarting the window, never summed, and older values kept in `rig usage series`; for Codex, tokens with the note that the inspected method (token_count events) reports no cost, never a dollar conversion. No cost, a non-number, a stale estimate, a series without the new columns, a sample with no session, or an unknown source each reads as not reported, stale or unknown, never $0; a real estimate under half a cent prints "<$0.01". Migration 105 adds two nullable columns (`native_session_id`, `client_cost_estimate_usd`) to `usage_samples`; existing rows are untouched, and migrations are recorded by name, so later-numbered ones still apply. Existing fields and text outputs are unchanged; `rig usage series` JSON gains those two additive keys (null on older rows), and the `rig usage` help lists the new `cost` command. No totals across seats, budget steering or account change. Refs slice 02 (K5, K6).
A registered person can also be a seat with its own pane. The destination resolver classes that address `pane-bound`, so `rig queue create --verify` reported only the terminal wake, even though the gateway may also post the row to that person in Slack. `GET /api/queue/:qitemId` now also returns `ownerNotificationLevel`, the level on the row's latest owner-notification transition (the one the Slack selector keys on), or null. When the terminal side applies and that level is set, or a gateway receipt already exists, `--verify` adds a `gateway` result: `posted`, a failure with its detail, or `still-pending` at the bound. The terminal side (`outcome`, `wakeResult`, `claim`) is reported as before, `humanReadership` stays unknown, and the wait keeps its 30 s default bound. "This is not a failed handoff" is no longer shown when Slack reports a failed delivery. A seat with no owner-notification level settles at once with no `gateway` field; gateway-routable destinations, registered aliases and `@external` keep the existing path; the create request, `--id` and `--no-nudge` are unchanged. With `output.compact` on, the short receipt keeps the whole `delivery` result, Slack side included. Refs #1029.
The gate-lane lock holds its lane by binding a local TCP port. Its server had no connection handler, so an idle local connection to that port (a port probe, `nc`, a monitor) kept `server.close()` waiting: the gate couldn't finish releasing its lane, and couldn't exit, until that connection dropped. The server now destroys each accepted socket at once. The bind is still the lock, so a live owner is never released early and a waiter still acquires the lane after release, now promptly. A test holds the lane, opens and keeps a client connection, and checks that release completes and the lane can be acquired again.
macOS ships Apple's openrsync as `/usr/bin/rsync`, and its `--stats` output reads `Total transferred file size: 20 B`. The parser accepted only GNU rsync's `bytes`, so on macOS `bytesTransferred` stayed undefined: `rig file copy --json` printed `null` and the human summary left the count out. The parser now accepts `bytes` or `B` followed by a word boundary. GNU output is unchanged, units it doesn't understand (`KB`, `MB`, `KiB`) stay undefined rather than becoming a wrong number, and the rsync command and its arguments are unchanged. The count is shown only; nothing decides on it. A unit test covers openrsync's form and the unsupported units, and a native test copies a real file with whichever rsync the runner has and checks the byte count.
With `output.compact` on, `rig whoami --json` lists at most `output.whoami_peers` peers (a new setting, default 8, env `OPENRIG_OUTPUT_WHOAMI_PEERS`), so the identity call every seat makes at boot stays small as a rig grows. Selection is edge-first: peers with an edge to or from the seat, then peers in the seat's pod, then the rest, each in the daemon's roster order; `edges` stays complete. When the bound cuts the list, `peersNote` says how many are shown out of how many, the order, the bound and the exact command that lists them all (with the same `--node-id` or `--session`), and an additive `peersOmitted` gives the count. A rig within the bound passes through unchanged plus `peersOmitted: 0`. Error answers print exactly as before. The new `--all-peers` prints today's compact output byte for byte and is forwarded on the SSH `--host` path; the remote's note names its own command. With the switch off (the default), every form is byte-identical to before, and `--full` and plain text are unchanged. The setting is registered like `output.compact` (CLI store, daemon twin, help, TUI label), and the `output.compact` help line now names queue writes, `queue show`, `queue transitions` and whoami peers. The startup guidance, the identity hint, two openrig-core skills, the capability map and three shipped team cultures add one conditional sentence naming `--all-peers`, true with the switch on or off. The daemon's `/api/whoami` is unchanged. Refs slice 15 P5.
When a person answers an ask with a Slack button, or reacts to an ask that's already resolved, the queue row those paths create already identifies the ask (it records the answer against it and names it in the row body). The row now also carries that identity as its `inReplyTo`, so a seat's `--reply-to` follow-up goes through the existing reply-to choice and posts in the ask's thread instead of at channel top level, the way follow-ups to typed thread replies already do. A reaction to a still-pending human gate still neither answers nor closes it, and its follow-up stays top-level, now recorded as `reference-has-live-gate`. Sender registration, the per-person answer check, unmapped, closed and superseded roots, and every refusal are unchanged. The follow-ups are ordinary thread replies: no `reply_broadcast` and no new mention, so they no longer show at channel top level. Tests drive the real inbound socket handler, queue and outbound delivery with a fixture Slack transport: a button answer and a resolved-ask reaction thread their follow-ups, and a live-gate reaction leaves the gate pending with a top-level follow-up.
When Slack's create-from-manifest dialog opens from `rig slack manifest --url` without the manifest, the person can paste the plain `rig slack manifest` output into Slack's YAML tab instead. `rig slack manifest --help` now says so, and the Slack setup guide adds that fallback to step 1. Step 2 now says that if Create stays disabled, go Back and check that a development workspace is selected and the manifest step shows no error, which matches Slack's documented flow (workspace picked before Next) without claiming a cause. Help text and docs only; command output is unchanged. A test checks the new help line. Refs #158.
`rig transcript <seat> --tail N` reads the transcript backwards in 16 KiB chunks and stopped once it had counted N newlines. When the file ends with a newline, that final newline counted toward N, so a chunk that began mid-line could stop one line early and return the first line cut. The reader now also needs the newline that starts the first requested line when the file ends with one, unless it reaches the start of the file. Files without a trailing newline, empty files, files shorter than N, N of 0 and CRLF endings read as before; when the chunk already starts at a line start, it may read one extra chunk and returns the same lines. The tail route (`rig transcript --tail`, the cross-host route and the UI's tail view) is the only consumer. A regression writes a 20 KB line followed by seven prompts and checks that `--tail 1` returns the whole line, with and without a trailing newline.
`rig capture --lines N` prints the last N lines of a pane, so a busy seat's activity indicator, drawn above the input box, can fall among the lines left out and the cut pane looks like an idle prompt. When `--lines N` left earlier lines out, the capture now says activity is unknown and where it is known: `--json` adds `activity: "unknown"` and `activityNote` ("lines cut; busy or idle not shown here; see rig ps --nodes or --history-plus-pane"), and the stderr note adds `; activity unknown: rig ps --nodes`. The note stays apart from the captured content: stderr for plain output, separate fields in JSON. Nothing changes when nothing was cut, for the default capture, or with `--history-plus-pane`, and capture never reported activity, so no known fact is replaced. CLI-only: the daemon capture route, the shared tmux adapter and their consumers (delivery checks, activity classification, resume) are untouched; local and HTTP cross-host captures share the cut, so both carry the note. Tests cover a busy Claude pane (the default and `--history-plus-pane` keep "esc to interrupt", a `--lines 3` cut reports unknown), the nothing-cut, default and `--rig` cases without an `activity` field, and the updated notes.
Behind the existing `output.compact` setting (off by default), `rig queue show` and `rig queue transitions` print a shorter default. `show` keeps the status fields, `pickup`, `waiting`, `blockedOn`, `closureReason`, the body preview, its marker and `readView` at today's paths, even when null; any other empty field is left out and counted as one `readView.omittedFields` entry, with a notice naming `rig queue show <id> --full --json`. `transitions` keeps the latest transitions, five in all and oldest first, always including the transition that set the current state or closure; the count of earlier transitions and the `--full` command go to stderr in plain and `--json` output, so the array stays clean through `jq` and the notice stays visible. A new `transitions --full` prints the whole log. With the switch off, with `--full`, and on error responses, both commands print exactly what they printed before, and exit codes are unchanged. No daemon route, response shape or setting changes. Tests cover both commands with the switch on and off, the kept fields, the disposition older than the latest five, the stderr notice, and the full reads.
For one-shot park timers, a `retained` attempt (the typing guard held the wake back) is no longer recorded as the timer's one fire. `findBlockedQitemsByWatchdog` keeps the binding after a retained fire, so the next attempt is recorded by `recordWatchdogWakeAttempt`; for the timer's own task the first non-retained attempt (ok or failed) retires the job (`park_timer_fired_once`), and for an attached task it consumes the attachment. `findLiveQitemsByAttachedWatchdog` no longer consumes an attachment on a retained fire, while a failed fire still does. `getStatus` keeps a retained one-shot `live` and not `unconsumed`, with `phase: fired` and `deliveryStatus: retained` in its history. There is no retry limit or deadline: retained attempts stay eligible at the configured interval. After a non-retained attempt on a generated job whose original park has ended, `recordWatchdogWakeAttempt` retires the job at once when the pre-delivery guard's reason applies (`park_timer_target_terminal`), instead of at its next check, so a later `--wake-watchdog` attach to it is refused (`wake_watchdog_not_live`) where it was accepted in that window before. Readers of the status after a retained attempt now see a live timer: the wake ladder defers to it, `rig queue parked` no longer flags the row, and leaving the set-aside state or a new park retires the old timer at once. Repeating queue-wait timers, standalone operator watchdogs, current-park selection, supersession and the ending of the original park are unchanged, and nothing bypasses permissions, the typing guard or the outbox. `keeping-a-watch.md` describes the retry and states that this is not a delivery guarantee. Tests cover the timer's own task and a shared timer through retained, ok and failed attempts, status, supersession, park endings, a late attach after consumption, and controls for repeating queue-wait timers and a standalone watchdog.
`rig bootstrap` (plan and apply) and `rig requirements` sent a relative spec path to the daemon as typed, so the daemon resolved it in its own working directory rather than the caller's. Both commands now ask the daemon status whether the target is the daemon this home started locally (a recorded pid from this home's `daemon.json`); only then is a path-like spec resolved against the caller's directory before it is sent. For every other target, an explicit `OPENRIG_URL` or `RIGGED_URL` (loopback and tunnels included), a configured or default host, or a local daemon without this home's record, the spec string is sent unchanged, so a remote daemon keeps resolving it in its own directory and a POSIX absolute path from a Windows caller isn't rewritten. Catalog lookups, the unlisted fallback and its ambiguity refusal, and `.rigbundle` handling are unchanged. Tests cover a recorded local daemon with relative and absolute paths, both URL variables with relative and nested paths, a Windows-style caller, stateless targets, recorded state over a configured host, stale state, catalog and fallback cases with an explicit URL, and the bundle controls.
`rig seat handover <codex seat> --source fork:<seat>` failed at start_agent with "codex fork: could not capture new post-fork thread id" whenever the adapter's immediate thread-id capture missed, after the retiree had already been stopped and the fork was running in its pane. Now, only when the caller is a seat handover (`ForkSource.pendingIdAllowed`), a missed capture returns ok without a resume token: the ready successor commits with no token, the parent's and the retiree's tokens are never copied, and `sourceOutcome` reports `forkId: "pending"` with the limit (until the fork's own id is recorded, exact resumption isn't established, a restore can't resume that conversation, and what restore does follows the seat's restore policy; `rig ps --nodes --json` shows whether an id has been recorded). A launch-time answer equal to the fork source's id or the retiring occupant's recorded id counts as not captured, checked separately. The late capture (`ResumeMetadataRefresher`, fill-null) never takes an id another session row holds for a forked occupant (`SessionRegistry.isForkClaimingHeldToken`). A spec member's `session_source: fork` and an agent-image fork keep the error and rollback on a missed capture, and launches that capture their own distinct id are unchanged; readiness, error and auth checks and the capture window are unchanged. An older CLI prints the handover without the pending fields; `--json` keeps them. Hermetic tests cover the handover with and without capture, parent and retiree answers including a cross-seat fork, the late capture, and the member and agent-image paths; no real Codex was run.
Send verification now takes a prompt class: `countEnvelopeOccurrences`, `verificationTranscript` and the input-line finder receive the shared composer class (`❯›»`) when the target runtime is `codex`, and the earlier `❯›` for every other runtime, including an unknown or null one. On a Codex 0.153 pane whose prompt is `»`, text still in the composer no longer counts as delivered, and a submitted envelope, an envelope after a reply and an envelope followed by a flat transcript boundary are confirmed. Verification on every other runtime is unchanged, as are envelope equality, indentation, terminal verification and the Claude-only styled-frame, question and composer helpers. Known limit on Codex panes: a `»`-led line below the live input is read as a boundary, as `›` already is, so constructed draft and quotation shapes (including an assistant quotation of a complete envelope) can read delivered, and a flat submitted message containing a `»`-led line can read unconfirmed; how often native renders show these is not measured. Delivered confirms visible transcript growth, not consumption; unconfirmed means inspect the pane rather than resend. Tests cover the Codex `»` matrix, five Claude draft and quotation regressions, three Claude flat-message controls and a null-runtime control, through public `SessionTransport.send` with real SQLite repositories and scripted captures.
A failed harness launch can now carry `missingConversation: true`, set only when the runtime itself reports that the requested conversation doesn't exist: Claude's "no conversation found" in the resume wait and on the final read after it (checked before the shell branch), Codex's "no saved session found", and Pi's missing session file. The startup orchestrator passes the mark through `fail()`, and the pod-aware resume branch of the restore orchestrator rolls the seat back to zero sessions and reports the original session unresumable only when `recovery` is `retry_fresh` and the mark is set. Before, `retry_fresh` alone triggered the rollback, so a Claude that went back to its shell for any other reason was killed, lost its pane and the error output on it, and was told its conversation was unresumable. That case now keeps the result it had before the rollback was added: failed, with the pane and its binding kept. `retry_fresh` is unchanged for its other readers. New restore tests assert the four outcomes (generic exit, missing conversation, attention, success), including whether the session is killed, the session state and the binding, and two adapter tests cover a report first seen on the final read and a generic shell exit. Effects on the terminal are mocked; no native runtime or physical scrollback was exercised.
When the human registry's projection file is missing and its fragments hold nobody, `loadHumanRegistry` still fails, now with "no human configured yet — register one: rig gateway human add …", the wording `rig gateway human list` already prints for an empty registry, and an internal `noHumans` flag. For that case only, the queue's refusal says the person can't be admitted because no human is registered yet, and its hint says to register them with `rig gateway human add` rather than repair a projection or downgrade the destination to an agent seat. The refusal code `human_registry_unavailable`, the 400 status and the reply's keys are unchanged; `registryProjectionError`, which nothing reads, is no longer set in the empty case. A registry with fragments whose projection is missing, drifted or malformed keeps its current message and hint. The gateway routes' 503 message, the Slack inbound refusal log and the health context show the new wording for the empty case, and every caller's decision is unchanged.
The kernel operator's role guidance gains one trigger line: when a person asks it, in any words, to watch something or remind them, it reads `$OPENRIG_HOME/reference/keeping-a-watch.md` first and follows it. The page is staged into every installed home at daemon start. The page itself now says that a handoff to another session closes the task and retires its timer, so the new owner sets the watch aside again; that the operator's own `--wake-after` timers list as `periodic-reminder` in `rig watchdog list` but are one-shot and retire after a fire; and the two exceptions: a held-back delivery leaves the timer live, and a timer another task shares through `--wake-watchdog` stays live for that task when this task is handed off or stopped, and still retires after its one fire. Every step on the page was exercised on an isolated private daemon from a packaged build; a native run with a real operator is not yet done.
The prompt glyphs that delivery recognises (Claude Code's `❯`, Codex's `›` and `»`) now come from one shared, runtime-scoped set in `composer-prompts.ts`, exported as `@openrig/daemon/composer-prompts`. The delivery checks in `session-transport.ts`, the startup submission evidence and the CLI's `send` use it. `promptAnswerStaged`, which decides whether to press Enter after typing an answer to a prompt, now finds the runtime's own input line with that set, so an explicit answer at a Codex `»` prompt gets its Enter. It keeps full equality against the answer, the input's closing boundary (a blank line for Codex, a rule line for Claude Code), the refusal of a numbered choice written `1.` or `1)`, and the refusal for an unknown runtime. A line starting with another runtime's glyph below the input's closing boundary no longer blocks an Enter for an exactly matching answer.
With the `provider.usage_windows` switch on (off by default), the USAGE section of `rig provider status` shows the Claude login's usage windows beside Codex's. Each window is the freshest status-line sample across Claude seats, at the sample's own time and naming its seat; an idle seat ages to stale, and seats whose fresh samples disagree on a reset make the level unknown, with each seat's own used percentage, reset and observation time shown. Identity comes from `claude auth status --json`, run only on an explicit refresh, by absolute path, in its own process group with stdin closed, bounded at 10 s and reaped; it is keyed by organisation and email and held in memory only. Facts are taken only from an exit-0 answer; Claude's documented logged-out answer (exit 1 with `loggedIn: false`) is `not_logged_in`, and any other nonzero exit is `auth_status_failed`. Rows are labelled as not proven to be the same login. A later read marks earlier rows stale for both providers. With the switch off, output equals main's.
OpenRig launches Codex by typing `/bin/sh <staged script>` into the seat's shell, so the pane runs shell, then sh, then the node launcher, then codex. The in-place restart accepted only Codex as the pane's root, or Codex whose parent is the pane's root shell, so it refused every OpenRig-launched Codex seat with `pane_shape_unknown` and stopped nothing. The `shell_backed` shape now also accepts one staged link between them: a process with exactly two arguments, argv[0] `/bin/sh` or `sh`, and an absolute path whose basename matches the tmux adapter's staged-script name. On macOS, where `/bin/sh` is a shim, the process name may be sh, bash, dash or zsh. The generator and the matcher share one prefix and suffix. It's a name check, not proof that OpenRig created the script. Every other shape still refuses, and the bound-pane, resume-identity, settings-drift and typing gates are unchanged.
A failed Pi or OMP turn picked its diagnostic with `??`: the first of `errorMessage`, `error` and `finalError` that wasn't null or undefined. It then cleaned that one field and printed "request failed" when the result was empty or not a string. An empty, whitespace-only, control-only or non-string earlier field therefore hid a useful later one. `errorNotice` now cleans each candidate in main's order and shows the first non-empty result. Where main showed text, the same field and text are shown. Field order, the 400-character limit, the credential guidance, success detection and duplicate-notice handling are unchanged.
After the wall clock moved backwards within a running daemon, `rig seat set-resume-token` could write to a superseded session. The canonical-name context lookup ordered by `created_at`, while node inventory selects the newest session ID. It returned success, left the current session's token unchanged, and attributed the audit event to the old row. The lookup now orders by session ID, which the registry's module-level monotonic factory issues for both registration paths, so the write target and the audit agree with inventory. With a forward clock the selected row is unchanged for both callers (the resume-token write and the generation-record read). A rollback that spans a daemon restart can still pick the older row, as main and inventory do.
When `rig seat clean` found a live session that wasn't the seat's latest managed session, it advised "Stop a live seat first with: rig seat stop". `rig seat stop` targets the latest session, so following that advice could stop a newer occupant and leave the actual blocker alive. For an adopted session, stop refuses. The refusal now chooses its guidance by comparing the live session with the seat's latest row. A current managed session keeps main's exact advice. Any other session is named, shell-quoted for `rig capture --`, with advice to end it only if it's no longer needed and to retry. The refusal code, message, early return and every cleanup, stop, launch and liveness decision are unchanged.
The TUI's Open terminals previewed the view without a width, while the window opener composes the plan with the measured width. On a terminal at least 120 columns wide, the unchanged `saved:kernel` view therefore got a different plan and was refused as changed. `previewTerminal` now appends `viewportColumns` when stdout is a TTY and the width is a positive safe integer. Unknown or invalid widths send main's request. Below 120 columns the daemon composes the same narrow layout, so the plan hash is unchanged. Provider selection, the plan hash and the changed-plan refusal are untouched, and a genuine topology or liveness change is still refused.
Stopping an on-demand seat with `rig seat stop` made an otherwise healthy running rig show degraded in `rig ps` and the TUI. A standalone stop whose kill tmux confirms now records a durable stop intent on the current session, written in the existing stop and audit transaction; the health rollup treats that seat as intentionally stopped, so live siblings keep the rig running while the stopped seat and the process counts stay visible. Later status writes clear the intent, a replacement binding disqualifies it, a crash still degrades the rig, and legacy exits default to unintended.
Restore selection keeps its existing meaning: the rig summary also exposes the full per-seat lifecycle as restoreLifecycleState, and `rig start` uses it for both the candidate filter and the later already-running check, so `rig start --last` and `--rigs <rig>` still list a partly running rig and report rig_not_stopped. The internal stop during `rig seat launch --fresh --stop` records no intent, so a refused replacement leaves the rig degraded, as before.
Refs #401.
`rig daemon start --port abc` or `--port 99999` reached the preflight port check and failed with Node's raw error ("Port should be >= 0 and < 65536 ... NaN"), and a negative port was forwarded to a spawned daemon that then failed. The CLI now checks the parsed port before preflight or startup and, for a value outside [0, 65535] or not a number, prints "Invalid --port: must be an integer in [0, 65535], got ..." and exits 1. The parse itself is unchanged (`parseInt`, base 10, as the daemon.port config key), so every value that starts a daemon today, including 0 and 8080x, still does.
The whoami runtime context hard-coded threadId to null for Codex, so a correctly bound Codex seat looked unbound in `rig whoami --full --json`. It now reads the latest session row for the node and returns its resume token as the thread id only when the token is a Codex id whose last probe found it resumable; any other state returns null, so an attempted resume, a failed probe or a successor without a captured token never shows a wrong or borrowed id. The Claude read is unchanged and now shares the same latest-row helper.
Adds `rig seat restart <seat> --keep-conversation` for Codex seats: an in-place restart that keeps the conversation and the pane, so the seat starts again on whatever account its Codex home now holds, without a fresh launch or a fork.
Before stopping the seat it proves which conversation is running, and `--plan` shows the result without side effects. It shows whether the seat is working, waiting for input or unknown, and leaves the timing to the operator. Pending model or effort changes can't ride along with the restart. A shell-backed seat gets back exactly the command it was running; a direct-native pane is held (remain-on-exit set and read back) before any signal, and a failed hold signals nothing. After a failed stop or respawn, the result reports the pane and process state actually observed. Peers sharing the Codex home are reported and never touched. Every restart is recorded, and a started record older than the daemon's own start is reported as stale rather than blocking.
`--verify` reports moved only when an Account line printed after the restart matches the expected account and a tool turn has completed after it; otherwise the account stays unverified. Account lines are compared by per-line counts across the whole pane, stored as digests, never as text.
A current Codex usage limit was masked whenever the terminal redrew. Inventory now keeps the typed unknown/usage_limit reading while the limit is current, compact `rig ps` text and JSON keep the reason, and the web activity labels show "usage limit". Stale or generation-mismatched limits still yield to current activity, and newer permission prompts, blocked visuals and startup errors keep their priority. Sending a message while a limit is shown no longer triggers a prompt override.
The banner detector recognizes the banner above a draft or an indented composer, and a limit clears only on positive recovery evidence: a newer turn-status row after the last submitted prompt, or a readable frame with no banner. Failed, blank and unrecognized captures never clear it, and nothing typed in an unsent draft, including its continuation lines, counts as recovery.
Known limitation: an old banner followed by a submitted prompt and assistant output, with no turn-status row and no composer below, keeps the limit and renews it after it ages out; main lets it age out. This frame has not been observed in a real session, and the same text can be an unsent draft above footer text.
Refs #763 and #824.
Extracts shared pieces that an in-place Codex restart will use, with no change in behaviour.
The Codex adapter's launch preparation (model, effort and profile arguments, posture and network decision, profile preflight, team workspace, daemon support) moves unchanged into prepareLaunch, with resumeCore and seatCommand around it, and gains buildResumeCommand and verifyResumeInPane for composing and checking a resume without sending it. The handover's TERM, wait and KILL cutover moves into pane-cutover.ts unchanged, with handover's remain-on-exit handling left as it was. tmux gains getPaneLocalOption, restorePaneOption and a pid-targeted signal, and the process-lineage module exports two thin wrappers over its existing parsers. None of the new functions has a caller yet. Every existing launch, resume, fork and handover gives the same commands and results as before.
rig provider status had no Codex usage windows: the provider service never called Codex's rate-limit read, so Codex rows carried no windows.
Behind a new setting, provider.usage_windows (off by default), rig provider status --refresh makes one bounded native read (codex app-server, account/read and account/rateLimits/read, 35 s, started with plugins and apps off) and shows the active login's windows: remaining, reset, observation time, freshness (fresh, stale, unavailable, aged from each observation) kept separate from level (nearing at 80%, exhausted at 100% or with the provider's limit evidence). Overlapping refreshes share one read, ordinary reads and wake-ladder ticks read memory only, and a timeout stops the reader's whole process group. Each row is keyed by the usage account id, else the workspace id from account/read, else it stays an unresolved row of its own; email is never identity. A failed refresh keeps the last reading with its original time beside the failure, and current availability reads unknown. Saved profiles that weren't read are named as not read. With the setting off, every existing route and printer is unchanged.
The context monitor probes readiness in the background and then wrote its result unconditionally, so a delayed result could overwrite a newer startup attempt: a pending or restarted seat became attention_required, or a newly failed retry became ready.
It now snapshots the eligible session (status, startup completion, binding, occupant generation and the latest startup event), compares a fresh read before probing, and compares again inside an immediate write transaction that also does the write; any change drops that poll's result and the next poll decides. An unchanged seat gets exactly the result it gets today, including one whose binding's tmux session differs from the recorded session name. The usage-sampling test now uses the canonical migrations.
After a restart, a pod-aware restore that asked to resume a session the provider no longer has (Claude "no conversation found", Codex "no saved session", a missing Pi session file) launched the seat, got a retry-fresh answer from the harness, and reported a failed launch, leaving the launched session and pane behind.
The startup result now carries the adapter's recovery hint, and restore treats retry_fresh there the way it already does on its legacy and fresh-continuity paths: it rolls the blank session back (the launched row is superseded, the prior binding and statuses are restored, the pane is removed) and leaves the seat awaiting a decision: "The blank session was rolled back; no session is running. Re-run with --fresh <seat>". Successful resumes and other failures are unchanged, and nothing starts a fresh conversation without an explicit choice.
A generic rig queue update <id> --state handed-off --closure-reason handed_off_to --closure-target <seat> returned success with no successor and neither the unverified-custody advisory nor the stuck-sweep diagnostic, because both paths treated every handed-off row as a verified handoff. Reopened rows re-closed toward a new target hit the same gap.
Both paths now apply the existing custody check to handed-off rows unless the row records a matching handoff: a local handoff, the deterministic cross-host successor id, or the older recorded <seat>@<host> closure that a cross-host re-drive keeps. Done and canceled rows are unchanged. It stays reporting-only: no write result, state or exit code changes. The sweep reads its candidates through the existing time and row-id indexes.
Known limit: after a reopen, a generic re-close naming the same seat on another host stays quiet, since the row records the seat but not the host; main reports nothing for handed-off rows.
Refs #318.
After a backward wall-clock step within a running daemon, rig ps could say stopped while node inventory showed a running replacement, or subtract a confirmed-missing replacement from a healthy sibling's count, because the summary picked each node's latest session by creation time while inventory picks it by registration order.
The summary now counts inventory's effectively running registrations and takes uptime from the same set: the earliest start among the latest running sessions that aren't confirmed missing. Wherever the two orders agree, the count and status are unchanged; a node whose latest session is stored running but confirmed missing no longer adds uptime, so a stopped rig no longer shows an uptime. The per-rig running-count query is replaced by one batched lookup.
Live inbound already accepted Slack thread replies sent with "Also send to channel" (thread_broadcast), but channel recovery skipped every threaded message and checkpointed past them, so a broadcast reply sent during a connection gap was never delivered.
Recovery now admits broadcast replies from the configured default channel's history. They take the existing path: sender admission, bot and subtype rejection, the fixed duplicate identity, exact thread routing and the durable retry, with the usual late-delivery marker. A reply seen live and again in recovery lands once. Ordinary (non-broadcast) thread replies and other channels stay outside recovery. The setup reference and the recovery status text describe the new scope.
Refs #739, which stays open for non-broadcast thread replies.
An idle Claude seat in auto mode, bypass permissions or plan mode (with or without the vim INSERT marker) showed a mode footer that the activity classifier didn't recognise, so the seat read as unknown instead of idle. The footer check now recognises those modes.
Mid-work evidence still takes precedence, and Codex panes are unchanged. The unframed-composer and earlier-selection checks keep the default footer's end-of-line anchor, so a default-mode footer followed by more text reads as it does today there. A negative test covers that case.
Reported in #808.
The rigs and openrig-skills descriptions both claimed getting a rig back, a watchdog wake and an OpenRig upgrade, and neither named the other, so the wording of a request decided which skill loaded.
They now divide by who is asking. rigs is a person in their own Claude Code or Codex session: install, set up, start, update, getting back to or re-attaching to their agents (including bringing a stopped team back), sharing or joining a team. openrig-skills is an agent already running inside a rig: fleet recovery, seat handover, watchdog wakes, in-rig upgrades and the rest of the index. Each description names the other in one clause.
Only the two description lines change. The package, plugin, canonical and shared-spec copies and scripts/skill-edge-digests.generated.json are regenerated from the sources.
Reported in #1171.
The default socket-path test measures the real home directory again instead of the hermetic test home, which on macOS pushed the path past the 100-byte socket limit, so the test failed there for a reason production never meets. Test only; `OPENRIG_HOME` is restored afterwards.
When a `--wait-for-idle` send re-waits inside the delivery lease and then times out, the message names the configured wait value instead of the leftover budget. The budget, deadline, lease, reason codes and every other timeout message are unchanged.
Follow-up to #1103, as lab1207 offered there.
Parallel `rig scope slice create` calls reserve different slice numbers without a mission lock, daemon or new setting, and numbers are never reused, including after a failed create or a deleted folder. Each create keeps a manifest's authored members, orders and active flags, adds its own entry, and repairs only unfinished registrations it can prove; older unlisted folders are never enrolled.
Changed results to know: a slice number is never reused once claimed; a hidden per-slice claim marker stays in `slices/`; under heavy concurrency a stale write can still omit an entry from `mission.yaml` (the slice folder stays on disk); and a failed create on a mission with no id yet leaves the newly written id in its README.
When a Pi turn fails and the provider puts its reason under `message.error`, `event.error` or `event.finalError` instead of `message.errorMessage`, the seat's error notice shows that reason instead of "request failed", through the same cleaning and 400-character limit as before. A null or missing `errorMessage` falls through to the new fields; an empty or non-string value still reads "request failed".
Queue writes (create, claim, unclaim, update, block, handoff, handoff-and-complete) can print a short receipt instead of the whole row when `output.compact` is switched on. It's off by default: with it off, or with `--full`, every write prints exactly what it does today.
Off by default: the receipt projection, and a `/healthz` read that settles `--host` locality for a compact receipt. Always present: the `--full` option on the seven write verbs (no effect while the switch is off), the `output.compact` key in `rig config`, the settings route and the TUI, one read of the setting on a write without `--full`, and the `unknown_target_repo` refusal message now naming `rig whoami --full --json | jq .workspace.repos` (same status and code). No migrations.
With the refocus work packet switched on (`OPENRIG_REFOCUS_WORK_PACKET`, off by default), a seat's held (blocked) and next (pending) lists are read separately, each capped at 50, so a blocked task can't be pushed out of "held" by newer pending ones, and the refocus script names which list was capped. With the packet off, nothing changes.
When `rig up` picks `/` itself as the launch folder (no `--cwd`, run from `/`), it prints one warning on stderr saying why and how to launch in the project instead. A launch at `/` is neither newly allowed nor refused: it was already permitted, and it can still fail for other reasons.
`rig up` warns when it defaults to `/`, from @Marius-prog's #226 (https://github.com/mvschwarz/openrig/pull/226) (Marius Sabaliauskas); finished by the OpenRig team in #1150, where his original commit is kept.
Co-authored-by: Marius Sabaliauskas <[email protected]>
A seat no longer inherits the launching Claude Code or Herdr session's identity variables (such as `CLAUDECODE` and `CLAUDE_CODE_EXECPATH`), whether the daemon starts the tmux server or joins one. The cleanup restarts only the seat's own pane, and a tmux error in it warns instead of failing a launch that works today.
Seats no longer inherit the launching Claude Code or Herdr session's identity, from @adampog's #771 (https://github.com/mvschwarz/openrig/pull/771) (Alan Dampog); finished by the OpenRig team in #1151, where his original commits are kept.
Co-authored-by: Alan Dampog <[email protected]>
The isolation test waits, bounded at 5 s, for the pane's shell (the pid its rc recorded) to exit before removing its directory, so a slow shell exit no longer fails the cleanup with ENOTEMPTY on macOS. A cleanup timeout is reported by name without hiding an earlier failure, and the directory is always removed. Assertions unchanged; test only.
When a queue list reaches its cap or fails to load, the TUI's instance, rig, pod and seat views and Pulse say the count is a sample or a lower bound. A seat missing from a partial sample reads as unknown work, genuinely empty data still reads empty, and a row that appears in two lists counts once. No new reads, and no daemon, query or setting change; the caps the TUI assumes (100, and 50 for pending) are a stated limit.
Refs #1172, #1173.
`rig proof` and scope readiness resolve a project-qualified scope (`<project>:<path>`) through the workspace catalog, bounded by that project's own root, and readiness agrees with `rig proof show`. Existing colon paths still resolve as written, and an unavailable project gets the same 409 by prefix or `--project`.
Changed readiness meaning: a catalogued project's readiness uses that project's own slice, mission or project policy, not a nesting ancestor's. Where none applies, the policy reads null (a slice with no judgment configuration can read legacy, and retained receipts whose scope or policy no longer match can read unknown), and rebinding a project without a manifest to a new scope root can change its revision identity.
Project-qualified proof scopes by @korallis (Lee Barry), with @dajiaohuang, from #136 (https://github.com/mvschwarz/openrig/pull/136); finished by the OpenRig team in #1149, where their original commits are kept.
Co-authored-by: Lee Barry <[email protected]>
Co-authored-by: dajiaohuang <[email protected]>
The daemon and CLI test suites pass on Linux: inode-swap tests no longer depend on inode-number reuse, a timed-out staging step's whole process group is killed, and the relay test checks the committed mode. Tests only.
Linux suite fixes by @mvdpoel (Martin), from #83 (https://github.com/mvschwarz/openrig/pull/83); finished by the OpenRig team in #1148, where his original commits are kept.
Co-authored-by: Martin <[email protected]>
A reference page for keeping a watch for a person with today's queue commands: the operator keeps a watch as a queue task, arms the timer with a blocked wake, re-arms it, and tells the person through the registered channel. No product code changes; a test checks that every command and option the page names exists.
`rig queue create --verify` reports `claimed` for an agent row the seat has already claimed whose wake render wasn't confirmed, instead of `indeterminate`; the exit code is unchanged. A script that matches a closed list of outcomes meets one new value.
A Claude Code seat started with `rig fork`, or a `session_source: fork` seat, launches naming its own session id, so strict restore and strict clear-attention can prove it as its own conversation instead of reading a mismatch. A newly recognized ambiguous fork chain keeps the existing warn-and-send result without claiming either conversation verified, and other conflicts are unchanged.
A parked seat is no longer woken again for the same rows when nothing about them changed since its last wake. The parked-owner consumer used to end a wake episode whenever the seat read not parked, including while it was simply working, so the next park with the same rows sent the same wake again. An unknown or old-format open episode still deduplicates until it closes.
`rig send --verify` confirms multiline agent messages whose continuation lines the agent indents. It compares the whole message block with whitespace collapsed, counts only new matches between the two captures, and leaves the current composer out, so a staged message, or one that only shares a long opening, still reads unconfirmed. Raw and terminal sends keep the existing 40-character prefix match. A program that prints a message without indenting its continuation lines and then prints output directly after it at the same indent reads unconfirmed; Claude and Codex both indent.
The TUI's Topology table reads the seat queue again on drilled views, so the Q, WORK and NOW columns fill: Q is a seat's open-item count, WORK its slice and NOW its current item. The queue is read only on a seat's page and the table tab, so the graph and health tabs make no extra calls.
Reported by @Quentin-M in #1172.
`rig seat launch --fresh --plan` previews a fresh seat launch without changing anything: it reads only and exits 0. Every refusal keeps its existing error code, and when an earlier occupant is still live the message names its session and warns that the stop command acts on the seat's latest session.
Refs #889.
A member's cwd in a rig spec can use ~ and $VAR. The path is used as written when it exists and expanded only when it doesn't, so every cwd that resolves today keeps its result, in launch, profile resolution and the legacy preflight. When the path still doesn't exist, the message names the expanded path.
Restoring a snapshot taken while a seat was stopped no longer starts that seat fresh without a word when its restore policy is resume_if_possible and earlier history holds a resume token. The seat reports awaiting-decision before any session starts, other seats restore as usual, and the apply path and every restore forecast agree. The message recommends a deliberate per-seat fresh start with the exact command and says the recorded token is unverified.
`rig config set` refuses a daemon port that can't be bound (such as -1, 65536 or 99999) instead of storing it, and a config file that already holds one resolves to the default with a warning instead of crashing the daemon at its next start. Ports 0 and 65535, quoted in-range ports and every value that resolves to a usable port today keep their result; the ui.preview settings are unchanged.
Pane discovery recognizes an agent by its exact executable name, including the 15- and 16-character names Linux and macOS truncate to and claude.exe, so an unrelated process whose name merely contains "claude" or "codex" falls to the banner check instead of being taken for an agent.
Watchdog jobs validate every scheduler cadence before saving: values that crashed with a 500 when stored (empty or nested arrays, booleans, single-element arrays holding a non-scalar, and scan values outside SQLite's 64-bit integer range) get a named interval_invalid error before anything is written. Every cadence that works today, including zero and negative values, keeps its result.
The wake resolver converts the requested generation to a number once and checks it, so a generation that can't index a tenure (a fraction, or text that isn't a number) gets the existing unresolved reply instead of a crash. Every generation that resolves today, including omitted, null and numeric strings, keeps its result.
Chat send and topic requests that crashed with a 500 (a null payload, a non-string sender, or an object or boolean body or topic with no array involved) now get a clear 400 before any message is stored or event emitted. Array, numeric and null-as-absent values keep their existing results.
The portability report runs its command-line entry point when started through a symlink or a path containing spaces or #, comparing decoded real file paths as the packaging script already does.
`--wait-for-idle` waits outside the seat's delivery lease and re-checks readiness inside it, so a long wait no longer holds up other senders to the same seat.
Refs #1084, reported by @z4cc, who also reviewed this change with six tests.
Reword the restore-guidance and restore-orchestrator comments, and two matching test comments, to explain the behaviour without internal references: an exact resume replays no startup content, except that missing managed guidance blocks are put back before the harness starts; existing blocks are never rewritten. Comments only.
The 0.6.10 CHANGELOG section, the packaged what's-new note and the capability map (marker capability-delta-v0.6.10), bound to the release content and its two regression fixes. Text only.
The TUI previewed Open terminals with no provider, which can now choose cmux, while the window opener previews and checks the Herdr composition, so the plans differed and the open refused on a machine without Herdr where cmux is running. The TUI preview now asks for provider=herdr, the composition the window opener checks. The launch still omits the provider, so a missing Herdr falls back to plain tmux; the plan check is unchanged; a provider-less open outside a window keeps choosing cmux. A new CLI test runs the real daemon routes, TUI preview and window opener for each case.
The activity lens builds its own rig clause and received the escaped rig pattern without an ESCAPE clause, so `rig view show activity --rig <name>` returned no rows for rig names containing `_`. It now uses the same `ESCAPE '\'` as the other lenses, and the literal-rig-filter test runs each name against both `recently-active` and `activity`.
A Claude seat restored with --resume T1 that Claude Code continues under a new session id can be proved again instead of staying attention_required. OpenRig records the rotation only for the process its own launch observed, bound to that process and used once, and accepts it as a separate clause after the unchanged exact-token checks. Migration 101 adds the rotation record.
Refs https://github.com/mvschwarz/openrig/discussions/1077
When tmux confirms a registered seat's session is gone, rig ps --nodes, rig seat status and the TUI show the seat as detached, with the cause and recovery in human output. It's a read projection: stored session status, occupant history, archive and cmux attach behave as before.
Once a seat claims a row, an unconfirmed wake result (delivered-ack-pending or indeterminate) reads claimed from the claim on, telling "delivered and acted on" apart from "delivered, unknown". Other results stay as recorded, a send is never promoted to verified, and the wake ladder is unchanged.
Refs #165
A ?limit= the daemon can't use returns 400 instead of 500 on the chat, projects, queue, stream and views routes. One shared parser keeps today's reading (-1, 0 and 10abc still work) and refuses only a non-number or a magnitude SQLite can't bind.
Strict restore no longer accepts a non-runtime launcher's --resume X as exact proof when the Claude it started is positively witnessed naming a different conversation. Every other shape keeps main's result, including an unreadable child executable and the opaque-child case, which remain disclosed limits.
Refs #1079
Before a team launches into the person's own project, the operator says what the launch writes there and what that changes for the person's own agent in that folder, and offers a separate copy instead. Text only.
A Codex seat whose codex command is a launcher that spawns the real Codex, rather than exec'ing it, is reached again by rig send and queue wakes, as on 0.5.17: the Codex path walks the launcher chain the way the Claude path already does.
Refs #1079
When a team seat stops at a prompt in a pane the person isn't watching, the operator's and the team leads' guidance has the agent the person talks to say which seat is waiting, what it asks and how to reach it. Text only.
The compact views of rig queue list, rig queue overdue and rig queue undelivered show every row's summary and leave out the fields they elide, instead of printing them as null or empty, so a search of a listing for a row's summary finds the row.
When the daemon runs inside tmux on a non-default socket, the attach commands OpenRig prints name that tmux server, taken from the daemon's own TMUX, so they reach the sessions instead of reporting no sessions. With no TMUX, commands are unchanged.
When an agent exits normally but its tmux pane stays alive at a shell, rig launch for that seat reports "Session alive, agent not running", names the preserved session and the ways back, instead of counting the seat as already running. Live agents, opaque wrappers, unavailable process observations and terminal seats are unchanged.
A new opt-in setting, queue.wake_human_rung (always by default, unchanged). With explicit-only, a wake ladder that runs out of rungs for an agent-to-agent baton ends at the open escalation row instead of posting a human-required alert to the person's Slack, and the baton's history records why.
Refs #811
When a Slack decision click can't be recorded, the person gets a short reply in the thread saying so and what to do, instead of silence. Click handling is unchanged, and a replayed click confirms the stored answer once.
rig terminal open from a tmux started inside a Herdr pane opens the view in that Herdr. Herdr is recognised by its full set of pane markers (HERDR_ENV=1 with HERDR_PANE_ID and HERDR_SOCKET_PATH) as well as TERM_PROGRAM; the CI and SSH checks still run first, and plain tmux is unchanged.
A Claude seat whose input line holds only Claude's own faint prompt suggestion is no longer treated as holding a person's draft, so delivery to it isn't refused. The verdict comes from one styled capture, only the current input line is checked, and the suggestion must match the whole line exactly. Typed or pasted text, placeholders, unstyled captures and wrapped suggestions are read exactly as before.
rig capture --lines N prints at most the last N lines of the pane, after dropping empty rows below its content, with a stderr note when earlier lines are left out. --json reports the returned count plus requestedLines and omittedLines, and --history-plus-pane keeps the old output. Without --lines, output is unchanged.
Starter and factory team guidance tells agents to write their first steps and checks in forms the team's default permissions already allow, so a team's own commands don't stop for the person's approval in accept-edits mode. Text only.
Four first-hour fixes from a first install on a fresh Mac. rig setup --providers claude|codex|both leaves an unchosen provider uninstalled and reports it Not selected; a chosen provider's failure still fails, and setup without the option is unchanged. A first rig daemon start no longer prints tmux's "error connecting" line. Setup's next steps say starting OpenRig and handing the person to the operator are part of the install. The operator's first copy no longer stops for an approval prompt.
A stub scenario covers the rig up round trip through the shipped CLI (up, ps, down, ps, up with the same spec, ps): the same rig name comes back as one running team. Test and docs only.
The transcript tail's final filter compares each line with the actual next remaining line rather than an index into the unfiltered array, so it no longer drops valid percent-ending output or keeps prompt redraw duplicates after status lines are removed.
The summary after rig workflow instantiate and rig workflow project reads the fields the daemon returns (the instance's id and status, the entry, next and closed prior packet ids), so a created workflow no longer shows "(no instance id)".
When no terminal provider is named, as from the TUI's open-terminal action, the terminal service tries Herdr first and falls back to cmux when Herdr isn't installed or answering, instead of failing with herdr_unavailable. With neither available, the honest failure and the attach commands are unchanged.
Refs #1069
After rig down and rig up --existing, a seat that resumes its own conversation gets its role, culture and SOP guidance put back in CLAUDE.md, CLAUDE.local.md or AGENTS.md before it starts. If some of that guidance can't be put back, the restore says so instead of fully_restored.
The queue's recovery lookup and the stuck sweep's dedup check read a small partial index instead of parsing every queue row, so the daemon stays responsive as the queue grows. Migration 099 is additive and index-only: one partial index over rows whose tags could carry a recovery or stuck-sweep tag.
A first rig daemon start can proceed when an unused loopback port silently drops the health probe (seen on WSL2). A timed-out port that is actually held is refused by an exclusive bind check before the instance is initialized or a daemon is spawned; an answering listener and a recorded unresponsive daemon keep their existing refusals. Occupied-port errors name the port, and child-exit errors come only from that child's own log bytes.
Refs https://github.com/mvschwarz/openrig/discussions/1049
A Codex seat showing its usage-limit banner at the bottom of its pane is recorded as a typed at_limit observation, so seat status shows the usage limit instead of idle and provider signals receive the event. Its state stays unknown, never waiting on a person, and a stale observation falls back to the normal reading.
Refs #763
rig queue create --verify for a seat destination no longer waits for a gateway receipt a seat never gets. It reports the saved row, the wake result and any claim separately, and human readership only for a person.
Refs #766
rig add accepts a rig name as well as its id, resolved like rig down: an exact id first across all rigs including archived, then a single active rig with that name. A name shared by two active rigs is refused before anything is added, with the matching ids. An unreadable summary falls back to the id-only path, and with a remote host selected names aren't resolved locally.
With --json, rig usage top and rig usage series print the daemon's HTTP error body as JSON on stdout instead of a plain line on stderr. Exit codes and plain output are unchanged.
A limited chat history with no starting point returns the newest messages instead of the oldest, still oldest-to-newest. With a since or after starting point it pages forward from there, as before.
When a rig named kernel already exists, the daemon reports the kernel as skipped with detail "kernel rig already managed" on every start. rig start now names that reason and prints how to bring the kernel back (rig up kernel --existing) instead of the --no-kernel or test-mode message. The opt-out message and JSON output are unchanged.
The 0.6.9 what-changed note, capability map and changelog, for the ten changes on main since 0.6.8, with migration 098 named, the Slack retry backlog counted as records, the first-install gap bound to 0.6.8 and how far it got, and the cross-host send budget and chatroom retry classes stated exactly.
Version fields only: 0.6.8 to 0.6.9 in the root, cli, daemon and ui package.json files and the matching package-lock.json entries. No dependency changes.
A Slack Socket Mode connection that says connected while events have stopped is noticed and replaced, by Slack's server pings and by the echo of OpenRig's own posts once a connection has shown it receives them. A replacement opens the new connection before closing the old one, at most once every 5 minutes, and rig slack status and the connections view say whether delivery is confirmed.
With the refocus work switch on, a seat's refocus can carry its actual current work as text, and the queue route can return work candidates when asked. Off by default: with the switch off, every path behaves as before. Migration 098 adds a (qitem_id, transition_id) index on queue transitions; no existing query is slower, and a 0.6.8 daemon still opens the database.
Refs #949
A retained Slack notification whose row has since closed no longer posts late after a restart, retry or re-enable; the row gets one "dropped" note instead. Notifications for rows that are still open, parked ones included, still replay and post, and decision-resolved notices and digests are unchanged.
A typed Slack reply to a structured decision is kept as the person's own words. It's stored as an object marked as a typed reply, holding the text, how it was placed and how many questions were left, while a click stays a plain option id, so typed text can never read as a clicked option. The formatter labels typed answers honestly, and the write stays one transaction with no double fill on redelivery.
Refs #1037
A cross-host rig send over HTTP waits 30 s (plus any --wait-for-idle). When it can't tell whether the remote delivered, it reports Delivery UNCONFIRMED and asks to check the target before any resend, instead of reporting unreachable. Unreachable is kept for errors that prove the request never arrived; local sends and the ssh relay are unchanged.
A long rig chatroom wait keeps going through slow or briefly failed polls, including a daemon timeout or a connection dropped during a restart, until its own deadline. A real error such as an unknown room still ends it at once and names the error, and a deadline end says so. The deadline stays exact.
A kernel whose boot failed reads as recovered, and rig status as ready, only when every seat in the kernel spec the boot used has a node with a ready session; if the expected-seat list can't be read, the failure stays current. Each kernel seat is listed once.
Refs #1042
rig slack status reports the durable inbound dead-letter backlog: the inbound Slack events kept for retry after a processing failure. When the dead-letter file can't be read, the plain output says unknown, and --json and HTTP report a null count, so an unreadable file never reads as an empty backlog. Read-only; no delivery path changes.
A Claude seat started through the nixpkgs wrapper reads as the seat's runtime instead of attention_required. The wrapper's process names only select a process for an OS path read; identity still requires a Nix store claude-code output's own .claude-(un)wrapped binary and every existing check (session token, foreground process group, frozen launch executable).
rig scope approve --workspace <dir> stamps the workspace the caller named. The CLI sends the root it resolved the target in, and the daemon uses it when it's an absolute path to an existing directory. A --workspace or OPENRIG_WORK_ROOT that names a tree with no missions folder stops with an error naming that source and its fix, and writes nothing. Clients that send no root keep the daemon's configured root.
Refs #995
After OpenRig's managed compaction, a quiet seat now gets its restore request even when its usage sample is stale or unknown, and never while it's still compacting.
rig launch <rigId> <nodeRef> --plan previews a single-seat relaunch, including that the other seats stay unchanged, and launches nothing; a daemon that can't report that says so and exits non-zero instead of showing the wrong preview.
The CLI and TUI builds prepare their output with Node instead of POSIX shell tools, so building works in a plain Windows shell. Originally contributed by @dajiaohuang in #956.
One catalog skill with uncommitted edits no longer stops every seat's skills from installing: that skill is skipped and named in a warning, clean skills install their committed bytes, and seat launch carries on.
A Slack post that gets a short rate limit (Retry-After of 10 seconds or less) waits and retries once, and a gateway restart during the wait cancels the pending retry so the message is never posted twice.
A Claude seat launched with Remote Control (--remote-control <title> or --remote-control=<title>) now confirms its identity through the exact session token instead of staying in attention.
rig launch --plan refuses, without sending anything, a daemon too old to honour plan (before 0.5.9), and exits non-zero with a clear message if an answer shows a launch instead of a plan.
rig workflow validate now reports step_cannot_finish (an error) for a step whose allowed exits leave out done and whose handoff has no next step, instead of letting the workflow stall there at runtime.
A reply a person types inside a Slack thread OpenRig opened now records the thread's owning item as its reply-to, so the seat's answer posts in that thread; a top-level message still gets a new message. Refs #822.
Keep typed commands whole in the TUI: the footer key is now F and the Scopes keys M and N, so feed, find, mission, narrative and needs can be typed from an empty command line. A test types every verb through the input handler.
In the TUI, j and k move the selection down and up when the command line is empty, like the arrow keys; once the line has text they are ordinary characters.
Check the Slack host inside the private-file download itself, so the bot token is only ever sent to Slack. The check is the existing one, moved; the one caller is unchanged.
Add a topology naming reference and point the RigSpec, bundle, onboarding and authoring guidance at it, so new teams name pods and seats by purpose, domain and role. Existing rigs and aliases stay supported. Text only.
Restamp the as-built docs to the 0.6.7 source 2caac7dd: moved citations re-checked, and the CLI reference matched to the 0.6.7 command surface. Text only.
Bring the agents' what-changed note and the capability map to 0.6.7: what behaves differently after an upgrade and what to do, and the map bound to the 0.6.7 capability delta. Text only.
A wake holds instead of typing into a seat whose latest valid hook says it waits on a person when the pane can't be read; the ladder escalates a held wake from the same evidence, and a later valid hook that shows the seat moved on ends the episode.
A bare slice name with --mission resolves past an unrelated same-named folder in the workspace or missions root; explicit paths, active-over-closed priority and the original error when nothing matches are unchanged.
Scope close, ship and move check the slice's path for edits with literal pathspecs, so a workspace folder named with pathspec syntax neither falsely refuses a clean slice nor misses edits inside it.
The handover, retirement and compaction skills teach seats to write their recap with rig context recap-write before a handover, compaction or long pause, with the format the recap store checks. Refs #996.
Getting started covers kernel seats that all fail at the first start on a tmux whose server exits on capture-pane: a throwaway-socket check, the reporter's recovery when no kernel seat is still running, and why a later start doesn't retry them. Refs #980.
After install the agent opens the OpenRig TUI and operator as the handoff, sized to the person's terminal (operator alone under 120 columns, 50/50 from 120, an openrig kernel space inside Herdr), reusing a space only under its own name with live attached panes. Supersedes #989, #990 and #992.
Scope commands update frontmatter fields by decoded YAML key, keeping the rest of the block as authored; frontmatter that isn't a valid YAML mapping is written as before, with a warning naming the file.
rig bundle check names the file in each human finding, and rig bundle create names the bundle after the team, while teams installed under the earlier default names still refresh in place.
OpenRig's herdr view starts with the sidebar collapsed via a private copy of the person's settings, falling back to the ordinary launch on any config problem.
rig terminal open opens the welcome view in the hosting terminal (Terminal, Ghostty, Linux terminals) and gives every other host a definite no-window result with the command to run.
The daemon installs the rigs skill globally at OpenRig's version, refreshed on each upgrade; a parity test keeps the packaged copy identical to skills/rigs.
Managed Claude compaction: the agent writes the temp restore map, and the daemon publishes it, so preparation needs no shell rename; openrig-core 0.1.6.
Repeated window opens reuse the existing herdr view when its plan matches, instead of adding duplicate spaces; a changed plan gets a fresh space with the stale one named.
rig terminal open leaves Terminal.app window size and position to the person; OpenRig no longer requests a resize that could reach their existing window.
Seat status follows the current occupant: stale predecessor observations and delayed absence probes no longer mark a healthy successor as needing attention, and restore-check names the right recovery. Refs #888.
Help, setup output and docs make the welcome screen discoverable: on a desktop the agent opens it with rig terminal open saved:kernel --window; over headless SSH it gives the attach command.
rig terminal open requests a desktop terminal window by default; explicit providers keep the existing-workspace route, and an outcome whose reply was lost is reported as unconfirmed.
Inline Claude settings JSON stays one value when OpenRig reads a seat's command line, so the real session option is found and the seat keeps its identity. Refs #921.
A reaction on any part of a long ask reaches the seat that asked, and a one-message ask that already landed is acknowledged instead of refused. Refs #899
Setup installs herdr by default and rig terminal open --window opens the welcome view in a new terminal, so the person copies no command; a failed herdr install is a warning and setup continues.
A Slack ask longer than one message is posted in parts instead of being refused, and a retry keeps the same split so no text is posted twice. Refs #897
Pi preflight reports the observed version and whether the managed seat has a stored credential, advisory only; no credential value is printed and a version or probe failure no longer stops launch.
Refocus after a managed compaction waits for the restore request (bounded at ten minutes), detects failure from the trace's exit status, reports repeated failures once, and seeds the bare refocusing skill.
Defaults no longer name the retired openrig-velocity rig: the advisor session defaults to the kernel advisor, and restore-packet defaults to the caller's own rig and the seat's LEARNED.md.
The operator checks health with plain commands, stops teams rig by rig with kernel last and hands off the final daemon stop, and relays rig up's untracked-file warnings.
rig doctor reuses setup's local authentication checks, so the two agree; existing doctor checks are unchanged, and the footer notes that only selected harnesses need a login.
A bundle target-file conflict returns 400 target_conflict from /api/bundles/install, as /api/up does, instead of a 500; refusal decisions are unchanged.
Shipped text for 0.6.7: the installer close lines in the packaged guide, the operator's Codex update line, and setup and policy wording that matches the team default.
Generated-file hygiene recognises the namespaced .codex/plugins/shared:openrig-core folder, so OpenRig's generated Codex plugin folder no longer shows as untracked.
The substance scanner can exempt an exact reviewed line (allowed_context_lines), so the compaction path's template line in the shipped restore skills is no longer a false finding.
The native transcript test waits for both copies of a typed line (echo and cat output) before comparing, so a capture between them no longer fails it intermittently.
The rigs skill carries an install through to the team's result on 0.6.6: it offers the OpenRig view, gives the goal to the kernel's operator, and follows the work back to the person.
A rejected web-terminal attach closes only that viewer with 1011 instead of exiting the daemon, and the failed broker is disposed so a retry starts fresh.
Refreshes the capability map agents read to the 0.6.6 release commit 2620dea8, and says the running-team check comes before target files are written, here and in the bundle reference.
Brings the README and the reference docs to the 0.6.6 release commit 2620dea8: claims checked against source at that commit, schema-2 and legacy bundle installs described separately, the permission-default exceptions kept in the short summaries, and plain wording for in-flight Slack work.
Brings the as-built documentation to the 0.6.6 release commit 2620dea8: claims checked against source at that commit, citations renumbered, the CLI reference regenerated from help at the cut, and each document stamped last-verified-against-source: 2620dea8. Describes 0.6.6, not later main.
Route parameters for seats, sessions, rigs, agent images and the terminal socket are decoded once (Hono already decodes them), so a name containing % no longer fails with a 500 or resolves to a different target.
The Claude status-line collector command shell-quotes its paths, so a project or state path with a space or apostrophe no longer stops context usage from reaching compaction and health.
Reinstalling a stopped team replaces conflicting files only in the folder that generation was installed into, recorded at materialization; a copied bundle.yaml elsewhere no longer authorizes replacement. Older generations and ones created directly from rig.yaml keep the manifest-only check until their next bundle install.
rig usage series --since/--until bounds are normalised to the stored UTC millisecond form, so a bound with a timezone offset or without milliseconds filters correctly.
The Claude adapter's non-managed launch quotes the resume token, so a token containing shell syntax stays one argument, matching the restore path and the --model and --effort arguments. The managed launch is unchanged.
The Codex resume check's final observation keeps a no-saved-session or attention-required result instead of falling through to the generic shell or timeout fallback.
Watchdog history retention keeps rows that tie at the keep boundary instead of deleting the whole tied group, so a job keeps at least its configured count.
Discovered and claimed tmux session names keep their literal form, including a pipe or leading and trailing spaces, in discovery and capture, so activity for those sessions isn't lost.
Launch-metadata reads now address the exact tmux session, so a session whose name prefixes another can't pick up that neighbour's identity. A missing target still falls back to the prior launch command.
The Claude activity relay now lives in the OpenRig instance (state/claude-activity-hooks/activity-relay.cjs), so a project cleanup can't remove it while its hook stays configured. Restore-check inspects the same place. Legacy project-local commands are replaced on the next projection, and user hooks are kept.
Refs #875
The execution view derives a mission's folded rung against the arrangement's declared source.integration_ref. The declared reference is recorded before composition validation, so after a composition error the folded rung reads indeterminate naming that ref instead of testing against main.
Refs #402
Working-directory project selection measured a project root's depth by splitting on the path separator, which counted an empty segment for the filesystem root, so / and /a both measured 2. A catalog declaring / beside /a then reported a tie from inside /a. Depth now counts non-empty segments; ordering among other roots is unchanged and a genuine tie still asks for --project.
rig skill audit stops failing on OpenRig's own shipped files. A skill copy that matches the same-named skill in an installed plugin byte for byte is reported as bundled from that plugin. Outside an OpenRig source checkout, the mirror-drift check reports skipped instead of failing; in a source checkout it runs as before.
Refs #802
A workflow step that was routed back and hands off again now reruns its dependents instead of leaving them satisfied by the earlier pass. Re-entry is ordered by append order, not closed_at, so a clock step-back can't skip a gate.
A seat sharing a projected plugin skill copy keeps it, with a plugin_skill_kept launch warning, when the plugin's source changed or moved, instead of failing the loadout. Only records owned by the same plugin are kept; the next launch that selects the plugin refreshes the copy.
Refs #815
The bundle install's manifest pre-check (unpack, parse bundle.yaml, schema-aware manifest validation) now always runs. --skip-version-check and --force keep skipping only the compatibility and conflict checks. A valid bundle installs as before; an invalid manifest that the install path would have rejected later now fails up front with a clear message.
Claiming a blocked queue item clears its former blocker together with the park-timer retirement, so the in-progress row and its waiting view show the claimant instead of the old gate. An escalation refresh on a claimed in-progress row keeps the retained claim gate.
Pi seats on minimax/* models can now receive MINIMAX_API_KEY from the daemon. The key crosses only when the operator names it in recovery.provider_auth_env_allowlist and it is set in the daemon's environment. The Pi child gets it only for minimax models; an allowlisted key is also in seat launch environments, as with the other Pi provider keys.
Refs #863
A needs-input zero clears a seat's prompt only when observed evidence supplied it; otherwise the hook store decides, so a seat at an approval prompt stays counted after a daemon restart or a hook-source downgrade. The wake ladder retires a prompt episode only on evidence observed after the refusal.
Refs #879
OpenRig team agents launch able to run ordinary rig commands, read the project and run its tests without permission prompts, while starting, stopping and reshaping teams still asks, and the operator's permission advice matches that default.
A rig can declare its non-interruptive launch choice, bundle installation carries it to launch, and the before-install view says plainly when a team runs with broad access.
The operator recommends a scoped permission option to someone unsure and explains what keeping prompts costs, and the CLI help separates the status dashboard from the operator's conversation.
rig setup keeps the route to the operator visible after a dry run or an incomplete setup, before the optional permission menu and in JSON as nextSteps, without claiming setup is ready.
Built-in starter and factory previews name all three first teams, and terminal-open results for interactive team views say where to talk about the work and ask whether the person can see the team.
The rigs skill carries on from install to a team doing the person's work: it starts the daemon, asks the goal, offers and starts a small team on a yes, and hands the goal to the owning seat, reading the result back instead of doing the work itself.
After launching a team, the operator makes sure the person can see it: it says where to look, or relays the team's attach commands unchanged when the view can't open, explains dashboard versus the lead's pane, and asks whether they can see the team.
The installing agent can now get the person to the operator's conversation without herdr or cmux: the install guidance gives an exact tmux attach command (safe from inside tmux), says rig tui --shared is the dashboard, and forwards later goals to the operator; a failed terminal view open lists the attach commands in its notes.
Installing a bundle for a team that is already installed now reports what is installed and the real choices (use it, stop and replace it, or cancel); a running team is refused before any write, and a stopped team is replaced in its own install folder with conflicting files backed up and the old generation archived, while other folders keep the first-install response.
The TUI opens the spec the person chose when a team and an agent share a name: a bare name prefers the team, and explorer rows, detail links, member links, spec-of and Launch keep the selected kind.
An agent installing OpenRig now keeps the kernel, treats installation as complete when the person is talking to the operator, and hands the person's goal and folder to the operator instead of building it: daemon start help, setup's next steps (launch commands labelled as the manual path), getting-started, the onboarding guidance, the operator's start steps and rig start's skipped-kernel message.
Adds the rigs skill at skills/rigs/SKILL.md, installable with npx skills add mvschwarz/openrig --skill rigs: it installs OpenRig if missing, starts or joins a team with existing commands, and explains how a session outside tmux keeps its identity and reads replies through the queue.
npm run test:repo now checks that every team, seat address, skill, rig command and path named in the shipped first-run text exists, reporting file and line for any miss; external workshop references are reported as unchecked.
Shipped docs, skills, help and onboarding text now name the new built-in teams: starter, workshop and factory, plus the code-review, research and pm shelf. first-project is mentioned only as starter's old name, Codex-only readers are pointed to the kernel operator to adapt a team, and secrets-manager stays listed as built in. Skill mirrors and edge digests are regenerated.
secrets-manager stays a built-in team: its rig.yaml returns unchanged, so it is listed and launched as before, and the library and packing tests expect it again.
A rig spec in the TUI now opens on the same graph an installed rig's graph tab draws, built from the served spec graph. A Launch action asks for an absolute working folder, shows the exact command, and runs the ordinary rig up through the same CLI, returning to the TUI on Enter. Ctrl-C during a launch interrupts rig up without closing the TUI.
The kernel operator asks the person's goal first, then offers starter, workshop and factory with one recommendation and a diagram from each team's spec. On a machine without a provider a built-in team needs, it writes an adapted copy of the whole spec folder under the same team name. The workshop installs once in ~/rigs/workshop. The operator launches only on a yes, reports readiness honestly, shows the new team without taking over the person's terminal, and hands the original goal to the team's lead.
The starter builder and the factory lead start from the goal the kernel operator hands them instead of asking again. For real continuing work they reuse a matching mission or record one light mission and slice with rig scope, then offer to plan and build; a question gets a direct answer. The factory advisor works as the idea and intent partner and hands outcomes to the lead. Each is a required startup file delivered as text at launch and restore.
The built-in team shelf now offers starter, factory, code-review, research and pm, alongside the unchanged kernel. Starter has a Claude builder and a Codex reviewer; factory has a lead, an advisor, build, QA, design and two reviewers, with the advisor reusing the PM agent through its own profile. first-project still starts starter, after exact library IDs and names, and says the team's providers changed and how a Codex-only machine adapts it. The folders of removed or renamed teams keep their CULTURE.md so existing rigs restore after upgrade. The implementer and QA defaults and the recommended skill routing no longer select test-driven development; the optional skill remains for existing user specs. factory-rsi and secrets-manager move toward openrig-world bundles.
Codex 0.160.0 shows its update menu with the header "Update available · <from> → <to>", which OpenRig's update-menu recognition did not accept, so a Codex seat stopped at the menu at launch and timed out. Both recognizers now accept the current header as well as the old one. A copied header above a live prompt is not treated as a menu; the existing skip safeguards are unchanged.
After install, the kernel operator is the first agent a person talks to. On first contact it greets them, asks what they want to work on and helps them pick a first-project starter: it shows the team from its real spec, plans it in their own project folder and launches only after they say yes, then reports honest readiness. The advisor no longer introduces itself. Kernel Claude seats may now run claude auth status and codex login status without an approval prompt; nothing else in their permissions changes.
After setup, the guided path now reaches the OpenRig view before any starter team: check the selected login, start the daemon if needed, read the kernel's actual state, and ask "Open the OpenRig view now?". Yes opens a new space without taking over the current terminal; No and SSH get the command for later. The person then tells the operator their goal and chooses a first project team. Setup's closing steps, the getting-started guide, help and the installing-agent onboarding say the same thing; started is not called ready.
The default saved:kernel view now opens TUI, advisor and operator on every kernel, including single-runtime ones, where the operator was previously hidden. The default view draws them as one row of three columns on herdr and cmux; saved views and other views keep the existing grid. Guide, help, setup and onboarding text describe the same view.
Corrects five source citation line numbers in the as-built architecture pages (stamped to 82eb4bed): two reversed ranges, one range that stopped short, and two single-line references that pointed at the wrong line. The behavior each sentence describes is unchanged.
rig doctor ended a run with no failures with "System checks look good." even when WARN or SKIP rows remained, and nothing said it does not check provider logins or whether agents can work. Its help now names what it checks. The closing line points at any WARN or SKIP rows and says this is not an end-to-end readiness verdict. It then names the selected providers' status commands and how to look at the team. JSON output, checks and exit codes are unchanged.
The install wrapper's Node advice suggested the distribution package manager without saying that it can supply an unsupported Node version. On stock Ubuntu, apt gives Node 18, which the package's compatibility check rejects. The plan and the missing-prerequisite message now name Node.js 22 or 24, say to check node --version, and point to the official installer or the getting-started guide's Linux route. Commands, checks and exit statuses are unchanged.
When rig setup ends with failed steps, it now names each failed step and its fix (for example a harness that isn't logged in, with its login command) instead of only pointing to rig doctor, which checks the system but not harness logins. It also says that only the harnesses selected for the project need a login. Exit codes and JSON structure are unchanged.
npm 11 and later can skip @openrig/cli's postinstall script, which checks that Node.js and the SQLite binding work together. The one-line installer now runs that checker itself after installing the package, stops with the checker's own output and exit if it fails, and says plainly when the checker could not be found. Nothing on the machine is changed to make the check pass.
From a blank-machine install on Ubuntu 24.04: the README and getting-started now name a Linux route to a supported Node.js (nvm or NodeSource, since the distribution's own may be 18), say cmux is macOS-only and optional, and explain that npm 11 and later can skip the CLI's postinstall Node.js and SQLite check with an install-scripts warning, which is expected, and how to run that check directly.
Adds scripts/install.sh, an opt-in one-line installer over the existing commands. It prints what it will do, installs the published CLI, previews and then applies rig setup, and on failure names the step and keeps the command's own output and exit status. --dry-run only prints the plan. It uses the newly installed rig even if an older one is earlier on PATH, and it is safe to pipe into sh. It discloses setup's broader effects (provider tools, tmux and cmux defaults; on macOS setup may start cmux) and does not log in, choose permissions, launch a team or open a kernel conversation. The manual install instructions are unchanged.
When the TUI shows plain-terminal attach commands because Herdr is unavailable, it now tells the person to widen the terminal until a wrapped command fits on one line before copying it. Tests cover the warning at the narrow detail width and the exact local attach command.
`rig terminal open saved:kernel` now works without editing YAML. With no saved kernel view, OpenRig builds one in memory from the installed kernel's actual bindings (advisor, operator and TUI; advisor and TUI for a single-runtime kernel) and opens it through the existing view and provider path. Missing or unbound roles are named absent with a reason; if nothing can attach, the result names every missing role instead of opening an empty view. A user's own kernel view still wins, and opening never starts, restarts or restores a seat. Getting started, help, the README, setup's closing guidance and onboarding now point to the command.
When Herdr is unavailable on the selected daemon, the TUI's terminal preview no longer stops at a dead end. It names the daemon and view, says to open a new terminal on the machine running that daemon, and lists each member's exact attach command (the composer's own pane command, with ssh -t for remote members). Nothing opens automatically.
Wires the contributor path to maps that already exist. developing-openrig's "Find your way" now points at the full rig --help list, the known problems in docs/reference/help.md, ROADMAP.md and the shipped skills index. It gives the capability map's checkout path, and after "How we judge changes" says where to look. CONTRIBUTING asks contributors to search issues and Discussions and check ROADMAP.md before proposing a feature. The openrig-skills and openrig-architect skills now send "changing OpenRig itself" to developing-openrig instead of a skill that does not exist. All mirrored copies and their edge digests stay in step.
Adds a first row to the contributor skill's "Find your way" table pointing at the capability map (rig context get onboarding-width/public-what-you-can-do.md), so a contributor's agent checks what OpenRig already does before proposing a new command or calling a gap a defect. Both skill copies (.claude and .agents) stay identical.
Adds one bullet to the contributor skill's "How we judge changes": OpenRig runs on agents working in context with tools, so before adding code for a behaviour, ask whether an agent with the right instructions and existing commands could do it; a skill or a page of guidance is often the better change. Both skill copies (.claude and .agents) stay identical.
Restamps the 13 as-built A-D pages to 82eb4bed. Citations move with their source: 174 line numbers are renumbered, and one cited block grows by a line. Adds the kernel operational default (who gets it, the Claude allow list, Codex full_bypass, the per-launch decision, the floor fallback, the kernel_default status source) and the plugin skills now carried in the managed loadout with their three warnings. Updates the file counts that changed.
Brings four reference pages up to the 0.6.6 candidate: kernel seats launch with an operational default (Claude acceptEdits plus a per-launch allow list including reads under home; Codex -s danger-full-access -a never) unless a policy, per-seat choice or named Codex profile applies. getting-started now says Codex network access is added only when Codex answers that no configuration layer or managed requirement governs it, and the launch is unchanged otherwise.
After installation, the installing agent tells the person it will open a terminal so they can see their OpenRig agents, then opens a new space with existing tools: herdr, else cmux, else a plain terminal window. The first space shows the kernel advisor, the operator agent and the OpenRig TUI for a dual kernel, or the advisor and the TUI for a single-runtime kernel; it never takes over the installing agent's terminal or an existing space. Getting started gives the same steps as exact commands for a person, with session names taken from the kernel inventory, and on a headless or SSH target it says so and gives the exact attach command. The README, help, onboarding guide and setup's final text point to it.
Seats of the rig named kernel get an operational launch default, carried through fresh launch, continue, restore and same-seat handover. Claude launches in acceptEdits with per-launch allowances for Skill, file tools and operational command families, including reads under the user's home; it is not bypass mode, and explicit ask and deny rules still apply. Its file tools are not workspace-limited for writes. Codex launches with danger-full-access and approvals never: unsandboxed host access within the OS user's rights, not a command allowlist. Explicit seat choices, authored policies and named Codex profiles keep their meaning, other rigs keep their defaults, and if the kernel lookup cannot run the launch keeps its prior permission behaviour. The kernel's culture and role guidance explain how to hold this authority and the lifecycle consequences of down, restore and import.
The agent-spec plugins row now says that Claude Code and Codex don't read skills from a projected plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into its skill folder (.claude/skills/<name> or .agents/skills/<name>) under their plain names, and that a same-named folder OpenRig doesn't own, ignoring letter case, is left alone with a plugin_skill_kept warning.
After a successful catalog read, the TUI now opens its ordinary work views even when no rig is fully running, so a person who has just installed OpenRig reaches their team without a hidden extra key. Start and return stays reachable for setup and recovery, nothing is started, resumed or reset by opening views, degraded and unavailable states stay visible, and any key the person presses still ends automatic entry.
The recent-transitions read no longer carries tags, summaries and session display columns through the scoped history window, the LAG and the final sort. Display metadata is looked up only for the newest qualifying rows, in the same statement and read snapshot. The scope-eligibility joins, the full per-item live and archive sequence, ordering and normalisation are unchanged, and so are the returned rows. The history scan and its sorts still grow with history; this reduces the working set.
A selected plugin's skills now reach Claude Code and Codex seats: they are projected into the project skill folders through the managed skill loadout, under their plain names, when a seat is instantiated. An edited, kept or unreadable plugin copy never blocks a reconcile or a launch: it is kept with a warning, and a deselected edited copy is left in place unmanaged. A plugin skills path that is a file or unreadable gives no plugin skills and a warning. An existing user folder that matches a plugin skill's name ignoring case is treated as the user's and left alone. Catalog skills that do not come from a plugin behave as before.
Scope: instantiate paths only. Seats that already exist get the plugin skills when they are next freshly instantiated; continue and restore reconciliation is later work.
When one of Claude Code's status warnings (for example a failed auto-update, tmux focus events or a usage-limit notice) sits below the footer, an idle seat in any permission mode (default, auto, plan, bypass permissions, with or without vim mode) now reads idle. Before, only the exact accept-edits footer did, so rig compact refused those seats and send --wait-for-idle timed out. A draft in the composer keeps its existing reading. A status line showing a running hook ("running PostToolUse hook · 3m 12s") now reads as active work instead of idle. Refs #808.
On the default (non-managed) Claude launch path, the fork command now passes the parent session id through the existing shellQuote helper, like the --model and --effort values beside it. An authored fork parent id therefore stays a single literal argument when the pane's shell runs the command. Valid ids produce the same arguments as before; managed Claude and Codex already quoted this value.
Thanks to @DeryFerd for the change.
rig context add now starts the local daemon when it isn't running, using the same local daemon-ensure path as other commands, instead of stopping with "Daemon not running". An explicitly selected remote host still wins over a leftover local daemon.json, and commands that don't need a daemon are unchanged. A selected host is normalized the same way as the client URL before deciding whether it's local, so LOCALHOST and IPv6 loopback spellings (::1, [::1], the expanded form) count as local.
The publishing guide's "Submit it to openrig.dev/rigs" section now says rig names are unique on the site. If registry/<your-team>.yaml already exists, the registry check says the name is taken; choose a distinct one, for example <taken-name>-<your-name>.
packages/tui/README.md: complete the Escape order for ordinary browsing, from an after-merge source check. Escape clears typed command text first; with history it returns from a spec detail, an open file or an external link and restores the previous filter; otherwise it closes an open health view, then clears a filter, then goes back (resolveEscapeAction, packages/tui/src/input.ts). Text only; no behaviour change.
Five corrections to the as-built architecture pages from an after-merge source check at fcaf1f8e. Text only.
- content-surfaces: a non-empty files or progress root value is not validated; malformed entries are skipped silently, so a malformed value can leave no configured roots.
- mission-control: a missing annotation, destinationSession or decision returns 400; a drop without reason is refused by the audit record and currently returns 500; resolve answers 409 qitem_not_leg1_parked for an item not parked on a human seat.
- coordination-primitive: transport:v1 is recorded only for a direct request from a known origin; otherwise origin-unknown:v1, relay:v1 or claimed:v1.
- workflow-runtime: a revision may change exception_routing, which applies to future occurrences only.
- living-notes-review: the TUI calls only the fleet review read.
Four corrections to docs/as-built/test-layers.md from an after-merge source check at fcaf1f8e. Text only.
- OPENRIG_REAL_CLAUDE_INTEGRATION=1 enables only a placeholder that checks the parent id format; no real-Claude fork test exists yet.
- Without tmux, some tmux tests skip and others fail; the release-tag tests skip without local tags unless CI=true.
- The stub carries the resolved floor or full_bypass posture, including one from permission_policy, into its runner, but ignores non-interruptive launches and does not exercise native permissions.
- rig seat launch <seat> --fresh needs --reason.
Corrections from after-merge source checks of the TUI README and one web UI as-built page. Text only; no behaviour change.
- packages/tui/README.md: Escape first cancels command editing; on a spec detail page or an open file it then goes back and the list keeps its filter; elsewhere it clears an active filter before going back.
- docs/as-built/ui/project-and-for-you.md: queue-derived cards supersede only matching action-required or approval event cards; only the workspace and mission headers show the remote host chip; the maintenance-notice dismissal is persisted alongside the theme choice.
As-built coordination and work pages re-verified against fcaf1f8e for 0.6.6. Text only.
- coordination-primitive: create and handoff return the committed row and deliver a staged wake intent after commit (#776); identity provenance on every write route; closure-field and blocker rules; the stuck sweep's routing and custody check; queue retention scope; route surface.
- workflow-runtime: how each exit closes its packet, human gates park the owner's packet on the human seat, keepalives and the boot sweep, exception classes, resume and abort effects, graph revision rules.
- workspace-primitive: a new section on the project catalog and project selection; slice indexing by folder name and slice: tags; the doctor's eight checks.
- mission-control, living-notes-review, content-surfaces: my-queue resolution, verb validation, actor provenance, notification triggers; approval never fires the freeze; file asset preview headers, health-summary constants versus settings.
The web UI as-built pages re-verified against main 104b78ee for 0.6.6 (previous stamps were from May). Facts only; the web UI is in maintenance mode. Text only.
- project-and-for-you: the classifier (human-gate gives approval), the attention merge and decision-first sort, the removed storytelling band, tab sets and defaults, the rewritten Dashboard.
- topology: TerminalLauncher, RigGraph for rig and pod scopes, rigs expanded by default.
- shell-and-routing: four missing routes, the /slices/$name redirect target, skill detail without FileViewer.
- library-specs-and-design-system: the Library page shape, seat details at /topology/seat/..., themes and the Pi and OMP marks.
- ui.md: the index stamp.
Corrections from after-merge source checks of the README and the spec, context and startup references. Text only.
- README: a local rig bundle install needs --target (rig up and GitHub links default to the current directory); the Codex network default applies only when Codex confirms nothing sets or restricts it; builtin:auto keeps a named Codex profile and Pi's resource trust.
- agent-spec: filesystem skill discovery is for Claude Code and Codex (Pi uses only the skills folder beside the RigSpec; OMP does none); the auto hint also installs content starting # SKILL as a skill.
- agent-state-taxonomy and agent-startup-guide: the STARTUP, ORIENTED and RESTORE columns need rig ps --nodes --full.
- lore-routing, refocus-channel, project-workspace: rig context profile needs --situation; the light refocus trace previews about 800 characters of each LEARNED.md; a matching --project without a catalog reports selectedBy explicit.
ARCHITECTURE, DESIGN and two package READMEs re-verified against main for 0.6.6. Text only.
- ARCHITECTURE.md: counts at c750304f (94 migrations through 095, 87 commands, 21 daemon exports, 15 scenarios, six runtime adapters with OMP extending Pi), middleware order, the four CI scenario cases, and current pointers.
- docs/DESIGN.md: the web UI is in maintenance mode; the dark token set and the three themes (default system); Pi and Oh My Pi marks.
- packages/tui/README.md: eight sections, one runtime dependency (yaml), rig tui and --shared, rig terminal open takes a view, nine tab names, the socket commands query.
- packages/test-system/README.md: fifteen scenarios, the transcript and capture cases run in CI healthy only, folder layout, and the four later scenarios carry planted negatives instead of seed steps.
Five corrections from after-merge source checks of the operations and process references, each re-read at a350c59b. Text only.
- stream-classifier-worker: when the worker's own deadline fires first, the attempt records a retryable error or exhaustion, not an abstention.
- slack-app-setup: rig slack enable needs a readable human registry; a missing or invalid one is refused, and unregistered senders are not delivered.
- sdlc-conventions: an invalid C1 header is a medium finding (all stay advisory); slice create registers in mission.yaml only when the manifest exists; media without a covering proof artifact shows missing.
As-built test-layers and arteries re-verified for 0.6.6 (stamp fcaf1f8e; the running-inside-a-seat note reflects #780). Text only.
- test-layers: the cli, daemon and TUI suites share the root hermetic setup from #780 (selector scrub, fixture home, fetch guard, HOME/XDG/CODEX_HOME redirect unless OPENRIG_E2E_REAL_CODEX=1); gated real-provider tests; the TUI layer; launch through the seat launch environment, which no scenario reaches; scenario coverage, run-pr-scenarios options and the stale README notes updated.
- arteries: launch now includes seat-launch-environment.ts and the configurable readiness window; restore-check fixed by #139 and #328; migration ordering by name; addressing and rendered-delivery scenario coverage is partial.
Eight corrections to the core as-built pages, each checked at a350c59b. Text only.
- adapters-and-runtimes: Codex with auto keeps a selected native profile, otherwise the workspace-write floor.
- agent-spec-and-startup: only the remaining after_ready actions run after interactive files; node.startup_proof_skipped is recorded only on fresh launches.
- architecture-rules-and-event-system: default sends never wait on busy or unknown, while --wait-for-idle opts into a bounded wait that can return unsent; delivery from the log applies inside withNotifyEnvelope, and legacy callers notify after commit; ANSI is stripped for tail and grep, not full reads.
- transport-and-transcripts: --dangerously-interact persists an audit record before sending and refuses without it; ANSI wording matches.
- daemon-core: transcripts are bounded periodic capture-pane snapshots, not pipe-pane.
Four corrections from an after-merge source check of two as-built pages, each confirmed at a350c59b. Text only.
- plugin-agent-image-context-pack: rig and seat selectors supply a default seat:LEARNED.md atom only to named profiles that request seat context; rig skill loadout --runtime inspects and reconciles only with --apply, and rig skill audit is a separate read-only audit; the compaction routes use the terminal bearer middleware, which passes through when no token is configured.
- lifecycle-snapshot-restore: rig seat handover requires --reason (dry run included).
Six corrections from an after-merge source check of the bundle and first-run references, each confirmed at source. Text only.
- rig-bundle: identical target files avoid a target-content conflict but the rig-name check still applies; a carried pack is named by its archive folder.
- agent-startup-guide: the Claude submission check is bounded and can leave a seat ready with its submission unverified or staged; one final readiness recheck after delivery; rig seat launch --fresh needs --reason, and --stop to replace a live occupant.
- getting-started: --non-interruptive hides the Codex notices only when OpenRig itself selects full bypass.
- developing: which CI jobs run npm ci and build.
Projects, context and chain-file references re-verified against main (aa4ee793) for 0.6.6. Text only.
- chain-file-convention and legacy-topology-migration: CRAFT.md and ORCHESTRATION-CRAFT.md are chain names; startup delivers the rig spec's culture_file; the walkers also read NOTES.md and fall back to README.md; the legacy topology fallback covers only rig context trace (refocus trace, seat: atoms and handover priming read topology.root only); only product-team ships topology defaults today.
- refocus-channel: the content file is set in the harness environment (specs have no env key); a LEARNED.md practice reaches running seats through the refocus trace, a CRAFT.md practice only through configured refocus content; hook time limits.
- project-workspace: install never overwrites an existing project folder; work-install without a catalog; slice: tags are the authoritative queue-to-slice link, with mention-matching as the fallback.
- knowledge-maturity, lore-routing, mission-install, planning-dial: which commands enforce stage values, the rig and seat grant flags, walk's --seat-grant and --runtime, and where approved-spec-dial is recorded and shown.
Commands checked against the help inventory of a clean build of a350c59b.
Front-door documents re-verified against main for 0.6.6. Facts only; structure, voice and the README anchor are unchanged.
- README: rig setup installs a missing harness (and cmux on macOS); rig seat continue for a seat stopped at a prompt; the npm install table names rig and openrig-tui and adds a bundle-install row; Codex network access on the plain sandbox; the OPENRIG_YOLO, builtin:auto and --non-interruptive launch effects; restore outcomes, bundle links and checks, and the default culture; later release notes in CHANGELOG.md.
- CONTRIBUTING: tmux version in bug reports, the docs guard, context packs not committed, the UI suite runs in CI, and the skills locations.
- SUPPORT: the agent help page URL, the support email already published in help.md, and tmux and harness details in bug reports.
- SECURITY: the provider and project files OpenRig writes, and the tmux and cmux edits made by rig setup.
Specs and permissions references re-verified against main (fcaf1f8e) for 0.6.6. Text only.
- agent-spec: every example now validates (hooks are refused; plugins carry them); effort, mechanic, import version matching, qualified and unqualified resolution, wider skill resolution at launch, plugin projection per runtime, guidance target and merge as applied, and the shipped agents table.
- rig-spec: version and permission_policy rules, workspace, member fields (effort, permission_policy, role, compaction_strategy, mechanic, session_source, starter_ref), restore_policy narrowing; checkpoint hooks and continuity_policy are stored but not acted on in this version; unknown keys are refused.
- edge-types: spawned_by direction corrected; delegates_to also names the queue escalation target; the Attach line does not use edges.
- scoped-operating-posture and agent-state-taxonomy: store-unavailable answers 503, multi-project selection order, which axes are served today, and the other state vocabularies on the same surfaces.
Commands checked against the help inventory of a clean build of a350c59b.
As-built core pages re-verified against a350c59b for 0.6.6. Text only.
- adapters-and-runtimes: a new Launch posture section (builtinLaunchPosture and resolvePermissionPolicyAttachment in policy-ref.ts, bound per seat in rigspec-instantiator.ts; bundle-behaviour.ts is the read-only preview), Claude and Codex posture flags (Codex full_bypass adds -a never), non-interruptive arguments, legacy resume adapters, managed launch and bootstrap, terminal providers, and readiness attention codes.
- agent-spec-and-startup: the startup sequence as the code runs it (permission selection, identity prompt, checked Claude startup text, startup proof, rig seat continue), the startup file chain, and a Specialist rosters section.
- architecture-rules-and-event-system: startup, delivery and launch invariants and a Telemetry read surface section.
- daemon-core: counts (94 migrations through 095, 87 commands), AppDeps construction order and middleware order.
- transport-and-transcripts: the send flow (delivery lease and held messages, pane-ID writes, CLI --verify) and transcript capture.
- codemap: the launch-posture, rosters and telemetry rows point at the new sections.
Changed citations were checked by printing each source line at a350c59b.
Four process guides re-verified against main for 0.6.6. Text only.
- sdlc-conventions: wave composition reads mission.yaml arrangement.waves, with the wave-map queue row only as the fallback when mission.yaml is missing or malformed; slice.yaml execution.depends_on takes precedence; the audit is rig scope audit --mission and exits 1 only on high-severity structural findings; the proof-lock does not fire a freeze; plan-lock --locked-artifacts and --re-approve described; scaffold outputs updated; stale section references fixed.
- product-journey-sdlc: selections carry explicit edges, some file must name the catalog, references resolve from the Git root of the repository holding the catalog YAML, and rig workflow guidance reads the effective selection.
- product-management-pass and release-boundary: the slice-create --intent flag and where the capability delta lives and expires.
Commands and flags checked against the help inventory of a clean build of a350c59b.
Seven operations references re-verified against main for 0.6.6. Text only.
- browser-access: the web UI works over http on the daemon port at any accepted address, including the Tailscale name; OPENRIG_ALLOWED_ORIGINS is needed only for a UI served another way, such as https through your own domain.
- slack-app-setup: invite the bot before rig slack verify, and register a human with rig gateway human add before rig slack enable, which refuses without one; verify warns on missing feature scopes.
- health-diagnosis, host-resources, inventory-request-diagnostics, stream-classifier-worker, telemetry: fields, bounds and stop conditions corrected to match the code.
Commands and flags checked against the help inventory of a clean build of a350c59b.
Node test suites run inside an OpenRig seat no longer inherit that seat's instance. The daemon and TUI suites now use the same fixture-home and fetch-guard setup as the CLI suite (test/hermetic-env.setup.ts). OPENRIG_* and RIGGED_* instance selectors are cleared before product imports, so tests cannot reach the person's OpenRig home, database or daemon endpoint.
Ordinary runs also redirect HOME and the XDG directories and clear CODEX_HOME. With OPENRIG_E2E_REAL_CODEX=1, the real HOME, CODEX_HOME and XDG directories are kept, so the opt-in real-Codex tests run as before. Explicit fixture overrides set after setup still apply.
Test-only; no product code changes. Controls against disposable outer audit, SQLite and HTTP targets fail with the old setup and pass with the new one. Not covered: subprocess globals and hardcoded filesystem targets outside the fetch guard.
Re-verifies `docs/as-built/architecture/lifecycle-snapshot-restore.md`, `packaging-bootstrap-bundles.md` and `plugin-agent-image-context-pack.md` against main `a350c59b` and restamps them. Corrects restore-check's verdicts (an `unknown` branch and a `restore-preconditions` check), where a pod-aware bundle's content is routed (inside bootstrap, before any seat launches) and how guided compaction holds `/compact` behind a restore map, and adds the features since the old stamps, including topology rosters, restore receipts, `rig seat continue`, seat handover, GitHub folder links, `rig bundle check`, configurations and package identity, System World, recaps, skill loadouts, `rig compact` and `rig fork`. Part of the 0.6.6 developer documentation refresh. Text only.
Refreshes `docs/reference/getting-started.md`, `agent-startup-guide.md`, `help.md`, `developing.md`, `worktree-builds.md` and `non-interruptive-mode.md` against main. Corrects a `rig seat set-permissions` example that didn't resolve, the startup layer order (culture before rig), the documented `resources.hooks`, which validation now refuses, and a known problem fixed in 0.6.5. Adds the recovery path after a startup gate (`rig seat continue`, the startup attention line), Claude's bypass warning and `--non-interruptive`, `builtin:auto`, the readiness window and startup proof, and `npm ci` plus build for development. Commands checked against a help capture of `a350c59b`. Part of the 0.6.6 developer documentation refresh. Text only.
Regenerates `docs/as-built/cli-reference.md` from a help capture of main `a350c59b`: 87 top-level registrations, 352 registered commands and 1,072 options (previously 85, 341 and 1,026). New since the last stamp: `rig bundle check`, `rig bundle configurations`, `bundle create --preset`, `rig seat continue`, `rig roster`, `rig telemetry`, and new options on `up`, `bundle`, `bootstrap`, `ps` and `seat set-permissions`. All 10 repeatable options are now marked. Part of the 0.6.6 developer documentation refresh. Text only.
Refreshes `docs/reference/rig-bundle.md`, `bundle-formats.md` and `publishing-a-rig-bundle.md` against main `a350c59b`: adds the flags they were missing (GitHub links, `--non-interruptive`, the install pre-check overrides, `--allow-drift`), states what `--plan` does and doesn't do, notes that `rig up` on a local archive skips the install pre-checks and audit, removes duplicated sections, and adds to the publishing guide what the optional `bundle.yaml` and `configurations.yaml` hold, what `rig bundle check` checks and exits with, the full-commit-ID rule for links, and what links need. Every command and flag was checked against a help capture of that commit. Part of the 0.6.6 developer documentation refresh. Text only.
`rig queue create`, `rig queue handoff` and `rig queue handoff-and-complete` return their committed queue state without waiting for a slow terminal wake. Ordinary create stages the existing deterministic wake intent inside its transaction; all three schedule delivery after commit, reusing the existing intent claim, finalize and startup recovery. A crash between commit and delivery leaves a pending intent that recovery drains once; an abandoned send stays indeterminate and is never replayed automatically.
If create can't retain the wake intent, including when the target's held-message quota is full, partial intent writes roll back, the task is still saved, and the receipt reports `failed:wake not retained: <reason>`. No quota is bypassed and no retry is added.
The receipt now confirms persistence, not notification: its `lastNudgeResult` is normally null at receipt time, and the wake result is read later with `rig queue show <id>`. Client-minted IDs, same-ID reconciliation and the changed-body warning, `--no-nudge`, explicit `--verify`, forwarded target acknowledgement and the best-effort path for callers without an outbox are unchanged. No exactly-once delivery is promised.
Refs #766
Re-verifies the as-built index (`docs/as-built/README.md`), `codemap.md`, the architecture navigation page and `frontmatter-schema.md` against main `a350c59b` and restamps them. The codemap adds a "Newer entry points" table naming the source starting points for GitHub-link bundle sources, `rig bundle check`, `builtin:auto` and the other built-in permission policies, non-interruptive launch arguments, the instance YOLO setting, `rig seat continue`, `rig context work-install`, rosters and telemetry, each with its nearest module page. Part of the 0.6.6 developer documentation refresh. Text only.
Distinct rigs whose names sanitize to the same string no longer share a default Compose project. A new rig's default project is derived from its rig ID; an explicit `services.project_name` is kept; and a re-import that replaces stopped generations inherits the project of its actual predecessor from the existing archive operation.
If the replaced generations recorded conflicting Compose projects and the new spec sets none, import stops before starting any service with `compose_project_conflict` (HTTP 409 on the import route), naming the conflicting generations and projects and how to set `services.project_name` and retry. No menu or prompt is involved.
Teardown of an archived rig no longer stops a Compose project that a live rig of the same name still records. It keeps the project and reports "kept project … still used by rig …" as a teardown error, so a local `rig down` exits 2 in that case, as it does for other teardown errors. Two live generations never skip each other's teardown. Rollback limits are unchanged: restoring selected configuration fields is not a full database or Compose-volume rollback.
Off macOS, `rig setup --dry-run` said the `brew` and `cmux_install` steps "would be attempted", but the real run skips both there. The dry run now reports them as skipped with the real run's own messages, which become shared constants. macOS output, step statuses and exit codes are unchanged; only those two messages differ off macOS, including in `--json`. If cmux is already installed off macOS, the real run checks it, while the dry run still reports it as skipped.
A mission name containing `: `, `#`, quotes or a leading `-` wrote NOTES.md frontmatter that YAML can't parse, so `rig workspace validate` rejected the new mission. The built-in NOTES template now writes `name:` with `JSON.stringify`, which is a valid YAML double-quoted scalar for any name, so every newly scaffolded NOTES.md quotes it. Plain names read back unchanged, names YAML would have typed (`1.0`, `true`) now stay strings, and existing NOTES.md files aren't rewritten. The same applies when a mission's intent sentence is used as its name. A custom `OPENRIG_NOTES_TEMPLATE` keeps `{{mission_name}}` and can use the new `{{mission_name_yaml}}`.
The kernel advisor's startup context said "The user is on macOS". OpenRig runs on macOS and Linux, so the context now says not to assume which, and to check (for example `uname -s`) when it matters. Text only.
Onboarding 02 said `rig context work-install` stops with `project_required` whenever several projects are declared. Since the work-install selection order landed, it first picks by the calling seat's rig and the working directory, and stops only when more than one candidate remains. The paragraph now says so and points to the selection order in `$OPENRIG_HOME/reference/project-workspace.md`, which the daemon copies from `docs/reference/` at start. Text only.
Adds the issue #728 reporter's Claude Code 2.1.289 question-picker capture from macOS (a 160x50 pane; the posted excerpt is lines 17-33, and the other rows are blank) and their one-line constructed wrapped-description variant as fixtures. Both classify as `attention` / `selection_prompt` with `❯ 1. us-east` as evidence, at 12 and 13 non-blank lines from the bottom. The variant is the shape that returned `unknown` on 0.6.4. Test-only; product code is unchanged.
An OMP seat whose model is written as `provider/id` for anthropic, openai or litellm now receives that provider's base URL (`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `LITELLM_BASE_URL`) alongside its key. Before, `collectAllowlistedProviderAuthEnv` dropped `LITELLM_BASE_URL` at launch even when it was allowlisted, and `buildPiChildEnv` dropped all three at the OMP child, so OMP on a LiteLLM proxy failed to start and OMP seats behind an Anthropic- or OpenAI-compatible gateway went to the provider directly.
A new `OMP_PROVIDER_EXTRA_ENV_VARS` map names these variables, and both gates read it. They differ on purpose. The launch gate admits each name in `recovery.provider_auth_env_allowlist` for any OMP seat, when the daemon holds a value; each name stands alone, so an endpoint can be admitted without its key, for a keyless local proxy. The OMP child forwards an endpoint only to a seat whose declared provider matches, taking it from the runner's environment.
Behaviour change for existing setups: a daemon that already allowlists `ANTHROPIC_BASE_URL` or `OPENAI_BASE_URL` for a Claude Code or Codex gateway now sends OMP `anthropic/…` and `openai/…` seats to that gateway too; before this change they went direct.
Other OMP providers get no endpoint variable, and nothing new reaches Pi, Claude Code or Codex seats. Seven new tests.
`resolvePairTimeoutMs` keeps the existing resolution of `rig host pair --timeout` (blank, 0 and non-numeric values use the 600 s default; values below 1 s use 1 s) and caps the result at 2,147,483,647 ms, the ceiling `rig ask --wake-timeout` already uses. A timeout of `Infinity` or an overflowing number such as `1e306` no longer leaves the wait unbounded when the target stops answering. A finite value above about 24.9 days is also shortened to the cap.
No input is refused and exit codes are unchanged. Outside `--json`, a note names the bound when it applies.
With a reachable target the pairing request still expires after 10 minutes. A target that has lost the request, for example after a restart, answers `pair_unknown`, which the loop keeps polling until the timeout; that is unchanged here.
Section 6 of the publishing guide now tells a submitter to add one file, registry/submissions/<your-team>.yaml, to openrig-world with three fields: repository (the GitHub repository URL), folder (the folder holding rig.yaml, or . for the repository root) and ref (a branch, tag or commit). The registry check accepts that file and says the submission was received. Opening an issue with the same three things still works. A maintainer then writes the full entry in the registry-entry.v1 format defined in the bundle formats reference.
Adds docs/reference/publishing-a-rig-bundle.md: how to lay out a shareable rig bundle folder, check it with rig bundle check, share it as a GitHub folder link at a full commit, inspect and install from that link (rig bundle inspect, rig up with --target and --cwd), choose a configuration with --preset or --seat, and submit it to openrig.dev/rigs through the openrig-world registry, including what review promises. It ends with a block of instructions to give an agent that builds a bundle.
The commands match the shipped CLI; the registry entry format is defined in the bundle formats reference. The guide is installed with the other reference documents under the OpenRig home's reference folder.
Claude Code 2.1.289 can still show a just-submitted startup prompt as its collapsed paste label, [Pasted text #N +M lines], when OpenRig looks 200 ms after pressing Enter, and accept the prompt a moment later. The single look read that as an unconfirmed submission, so the startup-proof instruction was withheld and some seats did not orient.
When the first look shows that collapsed label alone, with M equal to the newline count of the text OpenRig pasted, OpenRig now re-observes the composer every 200 ms for up to 25 looks until it reads clear (including the queued-message placeholder) or staged. Staged still goes to the existing content-guarded Enter-only retry, followed by a single look. A person's draft, ghost text, a collapsed paste of another length, OpenRig's paste inside a draft, or any other mismatch keeps its first-look verdict with no wait, as before. No key is pressed beyond the existing retry. Each send waits at most once, about five seconds plus capture time; Codex and Pi are unchanged. When the result stays unverified after that wait, the diagnostic keeps both the first and the last look.
A person clearing a collapsed paste of the same line count inside that window would still read as submitted; the instruction would then go into an empty composer, not over their input. The trigger depends on Claude's current collapsed-paste label; if a later version changes it, the previous behavior returns (instruction withheld, with a warning). Follows up #743.
Human output from rig up on a bundle link and from rig bundle install now lists each seat that needs startup attention, with the daemon's existing reason. When that seat has pending startup context, the reason ends with the command to finish it: After resolving it in <session>, run: rig seat continue <session>. Previously these commands printed only Status: partial, and the continue step was visible only in rig ps --nodes.
When rig seat continue is refused because no verified pending delivery exists, the human message now points at rig seat status for the seat's current state and says plainly that the response cannot tell an already-delivered context from one that was never pending.
JSON output, status values, exit codes, the daemon, the TUI and event names are unchanged.
When a restore cannot reapply a Claude seat's saved activity hooks (the relay or manifest is unavailable, or the settings file cannot be parsed), the restore now reports that as a warning instead of skipping silently. Restore status, the HTTP result and the exit code are unchanged, and existing settings are left as they were.
The Claude projection skip now requires both preserved startup context and an empty plan, so a populated preserved plan still projects, and fresh launches, including deliberate profile removal, are unchanged.
The lifecycle snapshot and restore as-built page now says that restore replays no startup files, actions, guidance or skills, except that the saved Claude activity-hook selection is reapplied before native resume, with a warning when that is skipped. Refs #273.
A tall Claude question picker can put its highlighted choice outside the recent-line scan, so an ordinary message could reach paste and Enter and become the answer when pane evidence is the only readiness signal. The current dialog is now recognized by its terminal navigation footer at the bottom of the captured view, joined to the contiguous option and description block above it, with columns preserved. Such a send is refused before input with the existing selection-prompt result. The general prompt window stays at twelve lines, and capture count and send policy do not change.
A picker left in history above a later, complete, empty Claude composer with a recognized mode bar or hint (accept edits, bypass permissions, auto, plan or shortcuts) now counts as finished, which removes a narrow false refusal; an arbitrary line under that frame does not clear it. Draft handling is unchanged. Fresh runtime hooks still take precedence, and ordinary sends to unknown or running seats remain advisory-and-send, so this does not protect picker screens bypassed by those fresh-hook paths.
Recognition is tied to the Claude Code 2.1.289 layout captured natively at 160 and 100 columns; other layouts fall back to the previous behavior. Committed fixtures keep only the dialog and dismissed-result bytes from those captures. Refs #728.
A fresh Claude seat that stops at Claude's bypass-permissions consent dialog now keeps its startup context and is reported as needing attention. After the person reviews and accepts the warning in that pane, rig seat continue <seat> delivers the pending startup context in the same conversation, without relaunching. The existing TUI continuation remains available.
The continue hint now appears for every fresh-launch attention gate that has pending context, including login and workspace trust, and the human rig ps views print the full instruction outside the table so it is not clipped; JSON output is unchanged apart from the hint inside the existing error text. Runtime readiness no longer marks startup ready while this occupant has known pending context, so a seat no longer reads ready without having received its startup text.
The continuation reuses the existing occupant checks and consumes pending delivery before sending, so repeated calls do not replay it; a command timeout reports an unknown outcome without retrying. Ordinary sends, queue delivery, the delivery guard and explicit wait-for-idle do not read startup status and are unchanged. Recognition is bound to the retained English consent menu, not every Claude version or layout.
Slack messages posted while the connector was disconnected could stay in channel history without reaching OpenRig. On reconnect and on the existing periodic retry, the daemon now scans available top-level messages in the configured channel and passes them through the existing admission, routing, attachment and queue path. Recovered rows say they arrived late and carry the original posting time.
A durable per-channel checkpoint keeps unfinished pagination across restarts. Live events cannot advance it; a page advances only after each message lands, is confirmed as a duplicate, receives a policy disposition or reaches dead-letter custody. An upgraded install starts once from the newest retained channel landing, with older history reported as unknown; without landing evidence, coverage starts at feature adoption. Slack plan limits qualify older history without stopping delivery of the available page, and that limitation stays visible in status after restart. New scan intervals end five seconds before the local clock so recent posts can become visible; transport and server failures become eligible for retry after five seconds, and an explicit Retry-After is honored.
rig slack status keeps its local configuration output and adds bounded daemon observations: socket state, scanned and pending interval, recovery limits, retry time and per-connector counts, with ISO times for people and exact Slack timestamps in JSON. It calls no Slack API. No new dependency, migration, setup step or outbound message. Thread-reply catch-up and dead-connection detection remain open on the issue. Refs #739.
Claude Code 2.1.289 can show a transient paste hint or the queued-message placeholder below its input box right after a startup prompt is submitted. The startup submission check did not recognize those frames, so submission stayed unverified and the startup-proof instruction was never sent; orientation then depended on the model acting without it.
The recognizer now accepts the transient paste hint, including the observed medium effort indicator, and treats the exact queued-message placeholder as an empty composer. A non-empty exact match against the expected text is checked first and still reads as staged. Collapsed pastes, unrelated drafts, ghost suggestions, other effort indicators and missing captures stay unverified. The proof-send gate and the guarded retry are unchanged: the instruction is sent only after a confirmed submission.
Replaying 143 retained Claude frames: 42 move from unverified to clear, 97 stay clear, and the four collapsed-paste and ghost frames stay unverified. This proves frame recognition, not native orientation; a real-harness run confirms that separately. Follows up #719.
Claude can leave update, tmux-focus and weekly-usage warnings below its input box. After a resume these could hide a ready composer from an explicit wait-for-idle send. A complete empty input frame is now recognized across the supported warnings, with columns preserved so an indented multiline draft continuation cannot read as an empty prompt.
A visible composer or mode hint no longer proves the turn is finished. The current-block check covers framed composers and unframed empty Claude prompts with accept-edits, bypass-permissions, plan-mode or shortcuts hints; current timed work stays active through indented task rows, and a scan that exhausts its 20 lines inside that block returns unknown. Warning-suffixed idle requires a complete input frame.
Ordinary sends to running or unknown seats keep their advisory behavior and explicit wait-for-idle keeps its deadline. Hook precedence, SessionStart handling, occupant generation, input operations and Codex legacy-footer precedence are unchanged. Native Linux runs at an earlier head passed the first guarded send after resume, and their retained idle screens still classify idle at this head; no native run was repeated at the final head. Refs #273; based on merged #731.
Remaining Claude startup files now use the same submission check as the initial prompt (one paste per body and at most the existing content-guarded Enter-only retry). A prompt that is still staged or unverified after that is reported as a warning on rig seat launch, restore status and receipts, import, bootstrap and the TUI, naming the pane to press Enter in only when staging was actually observed; warnings accumulate across later successes and failures.
The TUI shows multi-line launch warnings as wrapped, scrollable action details that survive layout changes and automatic background re-anchoring (intentional navigation dismisses them), and a failed follow-up status read keeps the successful launch result and its warnings without replaying the launch. Post-delivery readiness uncertainty has its own label for every runtime.
Readiness, exit statuses, async restore HTTP 202 and joined-resume identity rules are unchanged. The check adds about 200 ms per newly checked file when clear, and another 200 ms after a retry. Refs #729.
Clarifies the non-interruptive launch guidance and defines bundle-status assists.
- docs/reference/non-interruptive-mode.md: non-interruptive applies to builtin:yolo, a full_bypass flag policy or an explicit full_bypass selection; to turn it off, stop the rig with rig down <rig-name>, then run rig up <rig-name> --existing --no-non-interruptive (a running rig is refused before the saved choice changes).
- The --no-non-interruptive help on rig up and rig bundle install, and the launch summary, say the same.
- The before-install bundle view names rig bundle install <archive-or-link> --non-interruptive and says it doesn't check whether you've already accepted the warning on this machine.
- docs/reference/bundle-formats.md defines an assist: one action a person or the test runner took to get past a problem during the run, such as restarting a seat or re-running a command; following the bundle's own documented steps doesn't count.
No launch or status logic changes.
Adds two optional facts to the bundle-behaviour v1 view: firstRunWarnings.claudeBypass (Claude Code asks once per machine to accept its bypass-permissions warning) for full-bypass Claude seats, and nonInterruptive: available for full-bypass Claude and Codex seats, matching the scope of rig up/rig bundle install --non-interruptive. They describe what is available, never a selected option or the host's acceptance state; nativeEffect stays unknown. Pi, terminal seats and unresolved policies carry neither. The CLI view renders the new lines only when the facts are present, so older views validate and render unchanged.
Adds an opt-in --non-interruptive option to rig up and rig bundle install (including GitHub links). For seats with declared full bypass (builtin:yolo, a full_bypass flag policy, or an explicit full_bypass selection), OpenRig passes per-launch flags so Claude Code's bypass-permissions warning and Codex's full-access and GPT-5.1 migration notices don't stop the seat: Claude --settings with skipDangerousModePermissionPrompt, Codex -c notice keys. Nothing is written to the person's settings files; launching without the option brings the warnings back. The GPT-5.1-Codex-Max notice is not suppressed.
The choice is saved on the rig (migration 095 adds a non_interruptive column, existing rigs off) and carried into later launches, restore, resume, forks and handovers; --no-non-interruptive clears it, omitting the flag keeps it, and --plan never changes it. A launch.non_interruptive operator setting (default false) applies to new rigs; the CLI flag wins. The default launch path is unchanged.
Development builds from main now report 0.6.6, so the contributor bundle's min_cli_version 0.6.6 check accepts a CLI built from main. Updates the root, CLI, daemon and UI package versions and their lockfile entries; TUI keeps its independent 0.1.0; dependency resolutions and CHANGELOG.md are unchanged. Published 0.6.5 remains the current release; this does not publish or tag anything.
A Claude seat restored with rig up --existing keeps its selected activity hooks: an exact restore re-applies only the saved claude_activity_hooks selection before the native resume (legacy and pod-aware) and no longer strips the hooks through an empty contained projection. No guidance, skills, startup files or actions are replayed, and profile changes made since launch aren't applied on a contained resume. Seats whose hooks an earlier restore stripped get them back on their next contained resume.
Native result (Linux, Claude 2.1.220, one run per case): hooks restored before the resumed process was first observed, and retained through fresh, down and up, with the same conversations and current-generation SessionStart events. The first rig send --wait-for-idle after resume still returned no_activity_signal in both cases; that is not fixed by this change. Refs #273.
Refuses a package export name only when the path the install planner would write for it lands outside the selected install target. The check runs at the planner's own join for skills and agents on Claude and Codex, using path.relative from the target, and fails the plan with an ordinary plan error before anything is written. The tested contained destinations still install, including leading-slash, drive-letter-like, backslash, shallow '..' and empty agent names. Limits: validation alone still accepts such a manifest (refusal happens at plan and install), and this is lexical export-name checking only (no symlink containment).
Thanks to @DeryFerd.
Adds a first-launch auto posture through permission_policy: the built-in policy builtin:auto, or launch_posture: auto in a custom policy, at rig or member level. Claude seats launch with --permission-mode auto; Codex and Pi have no auto mode and launch at the floor, and rig seat status says why. Restore and handover keep using the seat's current policy (pod-aware restore included); NativePermissionStore.apply() re-applies only an explicit per-seat selection, plus auto for Claude when the binding's own posture is auto. rig policy list and show include auto, the before-install view reports it per runtime, and the rig-spec policy table has a builtin:auto row.
Thanks to @shravansumanthanan. Requested in #611.
A Claude pane labelled with its native version (for example 2.1.289) now completes a fresh launch or clears an identity mismatch when the existing process verifier proves the saved token, foreground process, ancestry, OS executable path and stable identity across two samples. The non-strict rebind previously rejected that label as ambiguous without checking the process. The label alone is never identity evidence; a missing token stays non-positive; strict restore and periodic identity polling are unchanged.
Refs #273 (symptom 2 and the identity branch of 4).
Each worktree's Git listing in the generated-file hygiene helper is limited to the cwd-relative .codex/plugins/openrig-core/ directory, plus the owning worktree's exact new guidance paths for the tracked-file check, using literal pathspecs with icase where that worktree's core.ignorecase is true. A large ignored tree (node_modules, build output) no longer overflows the 2 MiB listing buffer and skips the exclusions. The ownership rules, warnings, buffer and five-second budget are unchanged.
Inventory requests that carry a valid X-OpenRig-Diagnostic-Attempt UUIDv4 now produce bounded, metadata-only request-phase records: server arrival, Hono entry, handler entry and exit, the root list's SQL span, response finish/close/abort, and dated event-loop delay from the existing 250 ms tick. They reuse the existing Node request listener, first middleware, slow-operation worker and rotated private log; there is no new timer, endpoint, dependency or migration. Admissions, active traces, phases, age, record size and writer backlog are capped, and loss is counted instead of delaying or refusing requests. Untokened requests are unchanged. Root GET /api/rigs joins the route timing aggregate. Legacy slow-operation outcome ok remains an observation-completion marker, not HTTP success.
OpenRig keeps the files it newly creates under .codex/plugins/openrig-core/ out of a user's Git changes by appending exact, escaped entries to Git's own info/exclude in an OpenRig block; existing exclude bytes are preserved, and tracked, pre-existing or case-conflicting paths in this or a linked worktree are left visible with a warning. Newly created AGENTS.md, CLAUDE.md and CLAUDE.local.md stay visible: launch warns that the file is untracked and gives the optional exclude line, because Git shares info/exclude across linked worktrees. Git probes run once per worktree under one five-second budget; a timeout or failure warns and never refuses projection or launch. Warnings are returned through projection, startup and instantiation results. Entries persist after their files; existing entries are not removed, and plugin retirement at teardown is not included.
Native Claude installs can report a version-named process (for example 2.1.289) with argv0 claude. Behind the classic launch wrapper's nested shell, the strict identity check rejected them, so seats showed attention required and rig seat clear-attention could not clear a stale failed restore. For those otherwise-unresolved candidates only, OpenRig now reads the executable path from the OS (/proc/<pid>/exe on Linux; one bounded proc_pidpath call through /usr/bin/osascript on macOS) and accepts the process when the path is the native installer's versions layout with a basename matching the process name, or the executable OpenRig launched. A version string alone is not accepted; token, foreground, ancestry and two-sample checks are unchanged. rig seat clear-attention help distinguishes it from reconcile-session. Refs #273.
The before-install view labels per-seat lines with their seat. Runtime and login needs and per-seat unknown-before-launch entries gain an optional seat (pod.member) in openrig.bundle-behaviour/v1, and the human output prefixes them, and the existing also-runs entries, with the seat. Bundle-wide facts stay unlabelled, and views without the field print as before.
The skill_bundle_precedence launch warning is prefixed with the seat's session name and states that the managed catalog was not changed; the projection receipt detail now reads "projected exact selected source bytes". Selection and projection behaviour are unchanged.
Claude seats now receive one short line after a delivered startup challenge, once the startup prompt is confirmed submitted: OpenRig, which launched this seat, asks it to read its startup text and run the startup-proof command from its orientation challenge. The line carries no challenge id or answer. It is not sent when the preceding submission is unconfirmed or staged, and a failed send is recorded as a warning. Ready and oriented remain separate; Codex seats and launches without a challenge are unchanged.
A bundle's bundle.yaml can declare preconditions: [{name, commands?}]. Create carries them into the archive manifest for local and link builds, and the before-install view lists each as a need (kind precondition, status not_checked, with sourceRefs and the ordered commands); bundle-formats.md documents that the commands run in order in one shell and the install command runs where they leave it. OpenRig never executes them and adds no prerequisite check. A malformed preconditions block produces a create warning and is omitted instead of refusing the build; commands containing shell operators are kept with a warning. Package digest, configuration and source identity are unchanged; the archive hash covers the manifest.
A skill that a profile explicitly selects from inside its own agent spec's folder (an installed bundle, a rig folder, or an imported spec) now wins over a differing managed-catalog copy of the same identity, instead of failing the seat with skill_identity_conflict. Launch returns a skill_bundle_precedence warning naming both sources. The selected bytes and modes reach Claude/Codex managed reconciliation and Pi projection; the catalog is not written, unrelated selections are unchanged, and operator-edit protection still applies. Discovered and unselected copies still defer to the catalog. A qualified selection such as lib:shared is no longer matched against a catalog skill named shared; both are selected and project to separate targets.
The daemon's version helper prefers the build stamp, so packaged installs report their real version instead of unknown: bundle create records it as the assembler and provenance version, the before-install view and install audit name it, and GET /version returns it. Development runs keep the package.json fallback. A packaged daemon no longer refuses a bundle that declares a minimum daemon version merely because its own version read as unknown; only an actually older daemon is refused. rig bundle install and rig up print the daemon's error list as readable lines; JSON output and exit codes are unchanged. rig up --help names GitHub folder links.
Terminal tiles run tmux through the absolute path the daemon resolves, so a terminal provider with a narrower PATH keeps the tile attached instead of losing it; remote SSH commands still use the remote tmux. After a herdr page is laid out, one pane-list read marks a seat degraded only when the page's tab is confirmed and that seat's label is missing while another seat's label matches, or when only the layout's own filler panes remain; unclear or unreadable listings keep seats opened with a note. Seats with blank labels are never attributed. If every seat pane exits and herdr leaves unlabelled blank panes, the seats are still reported opened; the tmux-path fix removes the cause seen in the report.
The env teardown route selects volume removal only when volumes is the JSON boolean true. Strings, numbers, null, and absent or malformed bodies take the ordinary teardown path instead of being coerced to true. No new 400 responses are added. Teardown without the override still follows the rig's configured down policy.
Compose service logs now pass --tail only for finite values, so an explicit zero reaches Compose as --tail 0, while undefined and NaN omit the flag as before.
A successful rig capture with empty content now prints empty output instead of "(error: no content)", for both the local --rig path and the --host HTTP path. Exit status is unchanged.
bundle inspect, and the plan and apply previews of bundle install and up, now describe what an archive will do before installation, in the openrig.bundle-behaviour/v1 format: the team, declared permission posture (posture[].permissionPrompts: off, on or default where stated), supplied files, startup actions, writes, outside addresses, needs, and what is unknown before launch. The view is derived only from the archive's files, cites its sources, and is informational: existing installation checks still decide the result. JSON output remains one result, and an unavailable description is reported as not_generated without changing a successful inspection.
Schema-1 (legacy) bundle manifests now run validateProjectBlock, as schema-2 manifests already do. A hand-written legacy bundle can no longer pass a project.id or project.path that escapes the projects root or the bundle root to install. Manifests without a project block are unchanged.
rig bundle create, inspect, install and up accept a public GitHub folder link: the ref resolves once to a full commit, which is fetched with Git configuration, hooks, helpers and prompts disabled; the declared configuration is applied to an owned copy, and the existing bundler builds one archive. Output carries source, configuration ID, package digest, archive hash and assembler. On the link path, symlinks anywhere in the fetched checkout and agent, import and legacy package references must stay inside the checkout, or the import is refused and its folder removed. rig bundle check <folder> reports authoring findings without contacting a daemon. Existing path commands are unchanged.
Operating posture now picks a project the way work-install does when a row carries no project: the rig's recorded association, then the seat's working folder, then the only unclaimed catalog entry. So a mission-linked row no longer reads unknown just because a bundle install added a second project. The inference moved from the CLI into the daemon's project catalog, and both use it; an ambiguous case still reads unknown with its candidates. Posture records how the project was chosen in its sources.
rig bootstrap <team>.rigbundle now installs through the existing bundle-install route instead of parsing the archive as a YAML spec: it materializes to --target (default: the current directory), takes --cwd for the agents' working folder, plans with the same 120-second budget as bundle install, and accepts --skip-version-check and --force. YAML specs and library names keep their existing bootstrap flow.
CONTRIBUTING.md and docs/reference/worktree-builds.md now set up a fresh clone or worktree with npm ci, which installs the locked versions without rewriting package-lock.json, as CI does. npm install <package> stays the way to add or change a dependency, with the lockfile committed alongside.
A bundle can carry complete skills under skills/<name>/SKILL.md, with their helpers and references, and select them through profiles.*.uses.skills. Pi seats now discover the installed bundle's own skills folder and project selected skills into the seat's directory with executable modes preserved, and re-applying a directory skill refreshes changed, missing or new helpers. Skill existence is checked at resolution, when the installed pool is available; a missing selected skill still fails before launch. Claude and Codex keep their existing discovery and plugin routes.
Adds a 'Built-in permission policies' section to docs/reference/rig-spec.md: what builtin:locked, standard, open and yolo allow, ask about and deny; what each runtime launches with; that config-surface policies are recorded and take effect once translated into the runtime's native settings; how a custom policy file is written; and Claude Code's own first-run and root checks for bypass mode.
rig bundle create --preset <name> | --seat pod.member=runtime builds one of the configurations a bundle declares in configurations.yaml beside rig.yaml, applying the chosen runtimes and profiles to an owned copy of the rig folder (never the author's), and records the configuration ID in the manifest outside the package digest. rig bundle configurations lists the declared presets with their configuration IDs. An undeclared choice, an inconsistent configurations.yaml, or a recommended preset that differs from rig.yaml is refused before anything is built. Plain create is unchanged.
Inspect now runs the same unsafe-entry scan as install, through one shared helper, so rig bundle inspect predicts rig bundle install truthfully: an archive install would refuse (symlinks, hard links, absolute or drive-absolute paths, forward or backslash traversal) is reported as unsafe instead of being extracted. Install's refusals are unchanged; an entry that breaks several rules now reports its first reason.
Adds docs/reference/bundle-formats.md and v1 JSON schemas, with passing and failing fixtures, for the shared bundle formats: declared configurations, the before-install behaviour view, run records, the public status file and registry entries. Defines the configuration ID and the package digest with its stated coverage, and exports configurationId() and packageDigest() from @openrig/daemon/bundle-identity with shared test vectors.
The CLI's context-pack pre-check now accepts .mjs and .py helper files, matching the suffixes the daemon's manifest parser already accepts, so rig context add no longer refuses a pack the daemon would install.
rig bundle create --project-dir carries a project folder (project.yaml and its Markdown) in the bundle, and install registers that project before any seat launches: it materialises the folder at <workspace.projects_root>/<id>/ (never the user's clone) and records the rig's association in the workspace catalog (workspace.catalog_path). An existing catalog is never rewritten: the entry is appended as text in the file's own indentation and line endings, or a one-line rigs edit is made, each re-parse-checked, and the write is atomic and keeps the file's mode and any symlink. Conflicts (an id taken by another root, the same folder under another id, a rig claimed elsewhere, an unappendable shape, or a workspace project.yaml using the bundle's id) change nothing on disk and print the exact entry to add. rig bundle inspect shows the carried project and packs.
rig bundle create --context-pack <dir> carries a referenced context pack into the bundle: its manifest plus only the files it declares, so a pack at a repository root works. On install the pack is routed by its manifest name: copied when absent, reused when identical, and kept untouched when a different pack of that name is installed (for example a rig context add --git install), with a warning that names the rig context rm command to switch. It never merges into an installed pack. Install's human output prints each entry that wasn't routed with its detail.
Bring main's package versions to the published 0.6.5 (root, CLI, daemon and UI package.json, and package-lock.json) and add the 0.6.4 and 0.6.5 entries to CHANGELOG.md from their published release notes.
Add rig roster list, show and find to discover recommended specialists from authored JSON rosters in <workspace.root>/rosters (or --folder). Show and find match members to live seats at read time by host self-id and exact seat address, report the configured model separately from an unknown observed model, and keep missing, stale and unreachable observations visible. Rosters only recommend whom to contact; they never assign work or rewrite files. The inventory read uses a new rig ps --no-cleanup flag so it never removes stale daemon state; ordinary rig ps is unchanged. Non-regular roster entries such as a FIFO are skipped with a warning.
Bundle contents (context packs, skills, plugins, workflow specs, agent images) are now routed in a pre-launch step, after the rig record exists and before any seat starts, for both rig bundle install and rig up <bundle>. The live context library is rescanned so routed packs are served on the first turn, and packs the library rejects are reported. Routing problems become warnings and a failed route_bundle_contents stage in the response, the CLI output, MCP and the audit; they never refuse or roll back a rig, and nothing in the step can throw past the instantiator. rig bundle install gains --cwd, also honoured by --plan. The web UI's bundle screens don't show the new warnings.
Operating posture (rig mode, attention and the passive health check) now reads the project catalog named by workspace.catalog_path when it is set, through the shared catalog selector, instead of always reading workspace.yaml in the workspace root. With no explicit setting it keeps its default, the workspace.yaml beside the resolved workspace, so symlinked workspaces behave as before. A missing project root now reads project_root_missing instead of a raw ENOENT, and the catalog file no longer has the old 64 KiB read cap.
Add bounded, versioned metadata reads for telemetry consumers: GET /api/telemetry/v1/events, /queue-transitions and /nodes/:id/tenures, with rig telemetry events|transitions|tenures as single-page CLI readers. Each read bounds the rows and bytes it touches before filtering, selects explicit metadata columns only (no event payloads, notes, queue bodies, commands or environment), uses fixed-window opaque cursors, and names gaps instead of reporting missing history as empty: deleted or retained-floor history, a daemon restart, and withheld oversized fields. No schema change, writer, poller or subscription. docs/reference/telemetry.md describes the contract, including that a daemon restart invalidates a following cursor.
Add an integration test with three stub-rig journeys through the real CLI, daemon, SQLite and tmux: an addressed send renders in the target pane and not its sibling; rig scope listings are exact for the current rig, an explicit rig and all rigs; a tagged stream item replays after a daemon restart and a retried emit is idempotent. Document the journeys and their limits in the test-layer guide. No product change.
Managed Claude compaction keeps its restore map in the seat's registered workspace, under .openrig/compaction/preparation/<session>/<attempt>/RESTORE-MAP.md, and a generated .gitignore there keeps git add -A from committing private working context. The .gitignore is created only if absent and is never overwritten; an existing file is only read and checked. If the workspace is missing, relative or unwritable, or the folder can't be prepared safely, the map stays in the instance home as before. A failed compaction now says whether its preparation turn was delivered, uncertain or not sent, with the attempt and map path. The bundled restore skill follows the exact path in the request. openrig-core plugin manifests advance to 0.1.4.
A project.yaml can now declare install.worlds, an ordered list of world names to read after System World. rig context work-install lists them in order with the command to read each one; it does not deliver them or check that they exist. Invalid entries and later duplicates are skipped with a warning. When the key is absent, output is unchanged. System World's own selection parsing is shared and stays strict.
When rig context work-install is not told which project to use, it now chooses in this order: the project the rig is recorded against, then the project whose root most deeply contains the working directory, then the only catalog entry that no rig claims. The JSON position reports which step chose (selectedBy). If none applies, it still reports project_required with the candidates. Explicit --project and a single catalog entry behave as before, and no new refusal is added.
rig seat set-permissions now works from a plain shell: --operator names who made the change when there is no OpenRig session header. When the header is present it still wins. A missing identity, mode or reason each gets its own error. The permission record, the audit event and the effect (future launches only) are unchanged.
When rig context work-install cannot choose a project (project_required or project_not_found), text mode now prints each candidate with the exact command to select it (rig context work-install --project <id>, plus any --mission or --slice given). JSON output keeps candidates and adds a commands array. The exit code is unchanged.
A workspace context-pack folder created after the daemon starts is now discovered by the next rig context sync, without a restart. The daemon registers the configured workspace pack root whether or not it exists yet; the library already skips a root that is absent, so nothing changes until the folder appears. Precedence and the write target are unchanged.
After startup context is sent, the final readiness check now also runs when the bundled identity prompt, a restore preload or startup actions were sent, not only when startup files remain. A trust panel or other attention gate that shows up after those sends now reports attention_required instead of ready. If the check itself is unavailable, startup finishes ready with an unverified submission warning instead of claiming a gate. The initial readiness wait, send order and refusals are unchanged.
rig compact-plan's next step now names the claude-compaction-restore skill, which ships and covers preparing a restore map, compacting one seat and auditing the restore; it used to name claude-compact-in-place, which does not exist. Five sibling skill names with no shipped target (mental-model-ha, scope-recovery, composable-priming-packs, pre-maintenance-agent-preservation, writing-skills-for-openrig) are removed from the shared spec skills and their public mirror, and the edge digests are regenerated. No plugin skill changes.
A Claude seat launched with --effort now passes exact identity proof. Both argv readers in native-process-lineage.ts skip --effort <value> and --effort=<value> like the other launch options, so clear-attention can re-verify a shell-wrapped seat whose binding declares effort, and delivery no longer warns about unverified identity for it. Unknown arguments still give no positive proof. Three test rows cover the separated and equals forms and an unknown-flag control through strict proof and delivery.
Refs #197
rig mcp serve now builds its daemon client from the same URL its lifecycle probe reached, through the shared getDaemonUrl helper every other CLI command uses. With OPENRIG_URL or a configured host pointing at a non-local daemon, MCP tool calls, including rig_up, rig_down and rig_restore, now go to that daemon instead of the local one, and IPv6 or specific-address binds work. An explicit --port still targets 127.0.0.1.
The Slack gateway's JSONL state stores now start each appended record on a new line, so a record written after an interrupted append (a torn last line from a crash or full disk) is kept instead of being glued onto the unparseable fragment and lost. Every reader already skips blank lines. This protects the inbound dead-letter store's owed retries and the seen-event stores against loss or duplicate delivery after a partial write.
The shipped start-up text and the restore and reorientation skills now teach how an agent reaches its project's declared context: rig context work-install lists what the project declares (intent, context files, skills), the agent reads the ones its task needs, and --deliver prints them all. When several projects are declared it stops with project_required, rig context work-install --json lists their ids, and the agent picks one with --project instead of guessing. The placement skill gains a section on project knowledge: product facts, project judgment and machine-local state, and where each belongs.
Transcript tail and grep output now apply backspaces per Unicode code point, so a backspace removes exactly one character and never splits an emoji into a lone surrogate. Full transcripts, which restore uses, are unchanged.
GET /api/env no longer fails with a 500 when the cached service receipt is malformed: the cache is parsed inside the existing error handling, the response reports the receipt as unavailable with a parse error, and the next successful probe replaces it.
The experimental project input reader opens its file non-blocking, so a named pipe with no writer is refused immediately by the existing bounded-regular-file check instead of hanging the command. Regular files read as before.
rig mcp serve now finishes cleanly when its stdin ends: it stops on input end, on the transport closing, or on SIGINT/SIGTERM, removes its listeners and always closes the server. End of input now exits 0 instead of leaving an unsettled wait (exit 13).
Pi fresh launches, forks and resumes now reassert the seat's OpenRig instance and identity after the pane shell reads its startup files, using the same SeatLaunchEnvironment as Claude and Codex, so a pane rc can no longer point Pi's rig calls at another daemon. Unsupported pane shells and unavailable metadata keep the previous command. If a long temporary path prevents staging and the wrapped command exceeds the direct-input limit, Pi resends the original command, and routing stays best effort for that fallback. The Pi child environment filter, per-seat agent and session directories, resume-file selection and OMP behaviour are unchanged.
For an audited rig send --dangerously-interact answer, the transport now types the answer through the existing file and buffer transfer without bracketed-paste framing, so a numbered menu receives the intended key instead of confirming whichever option has focus. Enter is added only when the complete answer is still visible in the runtime's recognized text input (Claude's composer above its rule, or Codex's composer up to its blank line), so an answer the prompt already consumed cannot answer the next prompt. With --verify, the CLI's own explicit-interaction intent stops any automatic submit retry and reports the daemon's disposition when available. Ordinary sends keep bracketed paste, Enter and their repair. Upgrade the CLI and restart the daemon: an older CLI with a newer daemon may still press one guarded Enter. Runtimes other than Claude and Codex get no added Enter and are reported unverified. Builds on zichen0116's typed-answer approach in #602. Refs #519.
When a managed Pi seat fails because no API key is configured for its provider, the runner's existing error line now names the route that already works: set the provider's key in the daemon environment, add it to recovery.provider_auth_env_allowlist and restart the daemon, and use a provider-prefixed model, then relaunch the seat. It notes that default Pi logins are not shared with managed seats. A provider outside the known map is pointed at the seat's PI_CODING_AGENT_DIR. The hint names variables and settings only, never values. The ERROR prefix, readiness, exit and OMP output are unchanged.
When rig down cannot confirm a seat stopped (a failed kill or an unavailable tmux transport), teardown no longer strips that seat's managed OpenRig guidance from CLAUDE.md, the selected CLAUDE.local.md or AGENTS.md, and no longer deletes a guidance-only file the surviving seat still uses. A sibling that stopped successfully no longer strips a shared file first, and ordinary teardown applies the existing file-identity protection for files shared with another non-archived rig, as archived-rig cleanup already did. A later successful shutdown of the same rig cleans its file normally. Limit: while another non-archived rig keeps a node in the same folder, the shared file keeps its OpenRig blocks until that rig is archived or removed, or the next launch rewrites the same blocks. Kill, probe, refresh, snapshot and launch behaviour are unchanged.
rig project shadow-drain and shadow-stop accept --actor, so an operator shell outside a managed seat can name who is acting. The daemon's /shadow/drain and /shadow/stop routes read an optional body actor through the same sender check the other project routes use: a session header still wins, and a request with neither is still refused with actor_required. shadow-status is unchanged.
rig queue block now says that --on <blocker> is always required, and that the wake is chosen with --wake-watchdog, --wake-after, or a live blocker qitem. When a park names a qitem that is not on this daemon, the blocker_not_found message now says so and adds that a row stored on another host can be parked on with a typed gate such as external:<host>/<id> plus --wake-watchdog or --wake-after. The error code, payload, HTTP status and CLI exit are unchanged; there is no lookup or cross-host call.
When rig whoami or rig health --self reaches a daemon that does not know the seat's node or session, the not-found error now carries a hint naming the reached daemon and its URL, when known, and asks the user to compare OPENRIG_URL and OPENRIG_HOME in this shell with the daemon that launched the seat. The original error, status and exit code are unchanged. The hint makes one best-effort /healthz read on the same client with a one-second timeout, and omits URL credentials, query and fragment. Routing and environment propagation are unchanged.
Before the shutdown snapshot, a classic Claude seat's current conversation ID is read from Claude's own session file for the stable foreground process, so after /clear a later restart resumes the conversation the user is actually in rather than the pre-clear one. The read requires a matching seat name, a file written at or after the process started, a valid token and an unchanged process observation. Missing or ambiguous evidence keeps the existing shutdown behavior, and the existing provenance rules still protect hook, operator and adoption tokens. An archived rig whose name is now used by another live rig is not refreshed, so it keeps its own saved conversation. An unchanged token keeps its provenance. Shutdown now adds two process observations and synchronous file reads, which are not time-bounded.
A Pi installed only on the pane's PATH no longer fails the daemon-side preflight: a Pi missing from the daemon PATH now warns, and the pane launch decides, so a really missing Pi still fails clearly with ENOENT and exit 127. Pi is spawned by name with native PATH lookup, so symlink launchers that dispatch on their name, project-aware shims, relative and empty PATH entries, and fallback past a launcher with a missing interpreter behave as before. When Pi rejects OpenRig's managed flags at startup, the runner adds installation guidance and a version when known; the optional version probe is hard-killed after three seconds and never prevents a working launch. The runner also handles an early stdin pipe closure. OMP's early-exit reporting is unchanged.
In a fish pane, a classic Claude launch now sources the staged launch script in the pane's own fish when a fixed pre-launch probe shows fish accepts leading VAR=value assignments (fish 3.1 and later), so a claude defined only as a fish function or alias launches as it did when the launch was typed directly into the pane. Older fish, launch commands or paths containing a backslash, and wrappers too long for the safe input bound keep the existing /bin/sh staging, so anything that launched before still launches the same way. The probe uses /bin/sh -c :, which exists wherever the fallback already works, including macOS. Other shells, other runtimes and managed launches are unchanged; nushell stays on /bin/sh.
When OMP exits before its RPC transport starts, the runner now reports the phase and how OMP exited (exit code or signal) with OMP's last stderr lines, instead of asserting it was not a credential problem. OMP also exits that way when it has no usable model credentials, so the old message could point an operator the wrong way. The exit is recorded once OMP's stdio drains, or one second after OMP exits if a child process holds it open. Pi's behaviour is unchanged, and its early-exit test now waits for the runner's first request so it does not race the runner's first write. Closes#616.
A fresh Claude launch now records the session ID OpenRig assigned with --session-id, instead of the first session file in the Claude config folder whose name matches. Two live seats with the same name sharing one config folder could otherwise record each other's ID, so a later resume opened the wrong conversation. The fork path, which has no assigned ID, still matches by name; that limit is unchanged. Tests cover a single matching file, another same-name seat listed first, and only the other seat's file.
Acknowledging a working seat made inventory infer resumed, even when no evidence established that its original conversation resumed. This keeps the restore acknowledgment (operator_recovered) but leaves inferred continuity null unless the winning reconciliation carries strict restore proof bound to the same rig, node, attempt and completion receipt. rig seat status shows null as "unverified", including for seats that were never restored and have no explicit continuity outcome.
Explicit stored continuity values keep their precedence. A running acknowledged seat stays running, and applicable identity mismatches still require attention. Clear-attention decisions, recovery, delivery, tokens and lifecycle actions are unchanged. Restore-start reads are eligible for the existing node/type index, with a scan fallback when that optional index is absent, and no per-seat receipt queries are added. The user guide sentence that described the old behavior is corrected; plugin versions are unchanged.
When a queue item ID is not found on the daemon the CLI is talking to, the ten queue item commands (claim, unclaim, update, block, resolve, handoff, handoff-and-complete, fallback, show and transitions) now add a hint to the existing qitem_not_found error. The hint names the daemon by the host ID it reports, or says the ID is unavailable, and adds: if the item came from another host, run rig host list, then OPENRIG_URL='<daemon-url>' rig queue show '<id>' --full --json.
The error field, HTTP status and exit code are unchanged; the hint is an additional field. The host ID comes from one bounded read of the same daemon's health endpoint, made only after a not-found answer. Nothing is searched, no host is inferred from the ID, and writes are never replayed.
Explicit render=1 HTML previews again load without the script-free CSP, frame restrictions or nosniff, and SVG assets are served inline without forced attachment. This also restores the earlier header behavior for external script assets used by a preview.
Rendered HTML and directly opened SVG can run scripts in the daemon's origin. Default HTML remains text/plain. Authorization, the allowlist, path containment, response bodies, content types and range handling are unchanged from this change's base.
Validation: 63 focused route and header controls passed on the equivalent reviewed files, including full and partial JavaScript responses; the two new JavaScript controls failed against the previous route. No real-browser run was performed.
The harness readiness window used by launch, restore and seat handover is now configurable as runtime.readinessTimeoutSeconds (env OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS). The default stays exactly 30 seconds, and precedence is env, then config file, then default, through the existing settings store; rig config set applies to the next launch without a restart.
rig config set accepts integers from 1 to 600. An invalid env or file value falls back with a warning. If the settings read itself fails (for example a config.json that is no longer valid JSON), launch and handover fall back to 30 seconds with one stderr warning instead of failing. The handover timeout message now names the window it waited.
Refs #182. Contributed by Nikhi00718.
rig seat clear-attention now explains which attention class remains unresolved and how to correct a known native token before checking the live evidence again. The CLI help and the openrig-user skill distinguish operator acknowledgment from verified conversation continuity, describe the earlier identity-check path, and say which clears write audit events. The token example supplies stdin explicitly.
This is guidance only: clear decisions, error codes and recovery behavior are unchanged. It does not fix the separate projection that can still show resumed after an acknowledgment. The plugin version is unchanged.
rig chatroom wait --timeout accepts numeric-prefix values again. It tries Number first and uses parseFloat only when that produces NaN. Blank, non-finite and negative values are still rejected before any request; plain numeric, scientific, hexadecimal and fractional values keep their current behavior.
These are numeric-prefix seconds, not unit conversion: 2m means 2 seconds, 500ms means 500 seconds, and 1h means 1 second. Use plain numeric seconds, for example --timeout 120 for two minutes.
Refs #355.
Browser clients could pass the daemon's primary Host/Origin check and then be rejected by a second API or terminal WebSocket Origin check with different rules. This change uses the existing primary boundary consistently and removes the fixed refusal at 20 active host-pairing requests on both sides of pairing.
The removed checks are not equivalent predicates. Requests they rejected can now reach the existing route authentication, including explicitly configured non-HTTP Origin entries and configured cross-origin clients, normalized parse-equivalent Origins, and the enabled daemon UI's terminal upgrade on an accepted non-loopback host or IP without a terminal token. OPENRIG_ALLOWED_ORIGINS is now read once when the primary boundary is constructed.
The primary boundary still runs before the affected routes and token authentication is retained; its accepted IP/host rules, configured allowances and no-Origin behavior are unchanged. This is not a claim of universal DNS-rebinding protection or browser isolation. The standalone terminal registrar is token-only; its production mount supplies the browser boundary.
Pairing keeps its authorization, human approval, single-use handover, expiry, requester sanitation and failure cleanup, with no replacement cap. Pending durable approval rows can accumulate: expiry and arrival-triggered pruning clean the in-memory maps, not those rows. Notification URL handling is unchanged.
Not exercised: a real browser, a real tailnet, live pairing, Windows, sustained load.
rig package validate and rig package plan stop printing the duplicated warning about deferred role-hook references, and the optional warnings field leaves the validate/plan API and CLI output. Manifest validity, exit status, manifest errors and meaningful deferred plan entries are unchanged.
This package path does not install hooks today. A stale hook reference again gets no warning and no deferred entry. The role-hook validation requested in #477 is not implemented here.
Refs #477, #478.
A non-numeric recentLimit on the queue whoami route (for example abc) reached SQLite as LIMIT NULL and failed with a datatype mismatch. It now returns 400 "recentLimit must be a number".
Values that parseInt reads today (5, 1.5, 2x, -1) behave exactly as before, with the existing 1..200 clamp. An absent or empty value still uses the default. The new route test covers abc (400) and 5, 1.5, 2x and -1 (200).
Contributed by Coder8124.
After an operator-route prompt alert was positively cleared by its producer, the wake ladder's ownership check handed the original row back, but the executing ladder still skipped every terminal recovery and the readback still reported it resolved. A claimed failed-class original, or a pending failed or retained baton, then received no further retries. Ownership, execution and readback now share one predicate: a terminal recovery is skipped unless it is a prompt alert retired by its own producer with the exact retirement note, in which case the original's ordinary wake continues.
Manually closed alerts, unknown prompt states and every other terminal recovery remain held as before. A continued wake draws from the original row's existing retry count, cap and destination budget, and an exhausted original stays exhausted; nothing is reset and no watcher is added. The ordinary claimed case still gets one wake, and the readback now reports the actual eligible retry with the same interval and cap as execution. Timings in the tests are virtual; no production timing is claimed.
Refs #81
A map-required compaction preparation for a seat whose current occupant has no generation could never succeed: the map it waits for cannot be matched to a null generation, so the request ran out its full budget and failed. Preparation now checks both existing generation resolvers first and, when neither yields an identity, stops immediately with a reason that names the condition and the supported recovery: run rig reconcile-session '<session>' --no-launch, then start a new rig compact attempt. No preparation prompt is sent in that case.
Nothing else changes: skip-map still works with no generation, an active attempt still cannot be taken over, the 120 s manual and 180 s HTTP budgets are unchanged, and cancel, expiry, disable, late-map and stale-generation handling stay as they were. The fix never invents an identity, reconciles a seat on its own, or rearms a stopped attempt; only a new deliberate attempt after reconciliation freezes a new generation and map.
Refs #610
The Slack outbound sweep built the full queue view (pickup, waiting and recovery state, activity and delivery ledger) for every active row before checking whether that row's owner notification had already been delivered. Selection now reads active row IDs with the same filter, order and million-row cap, keeps the existing latest-notification and exact-receipt checks, and builds the full view only for rows that still need delivery. Slack projection, human-alert filtering, receipts, seen state and dispatch are unchanged; a row that is gone or no longer active is not posted, and a failed read still fails the sweep rather than counting as delivered.
In a synthetic 200-row queue with 2 undelivered notifications, full views fall from 200 to 2 and the median sweep from about 335 ms to about 27 ms. When every active row carries an undelivered notification the extra point reads make the sweep roughly 9-11% slower. It is still a scan of all active rows, delivery remains at-least-once, and no live CPU or health improvement is claimed. Enable-time seeding and the delivery digest use the same selection and keep their behavior.
Refs #161
Based on korallis/agent-stack#139 by korallis and Lee.
A failed rig export write could replace the saved rig.yaml with partial output. Export now uses the config store's existing staged write, moved into one shared helper: complete content is written beside the target and renamed into place, so a failed data write leaves the previous file intact and removes the staged copy. File modes, POSIX ownership, symlink following and the fallback for unwritable directories or busy targets are kept, and write-only destinations are accepted.
A destination that already exists and is not a regular file, such as a pipe behind /dev/stdout, a FIFO or a device, is written directly as before, so streams and devices are never replaced. The config store keeps the same behavior through the shared helper. Tests cover partial-write failure through the real export command, FIFO and stream destinations, symlinks and long file names. No ACL preservation or power-loss durability is claimed.
Contributed by @rudycelekli.
restore-check now runs restore's own pre-restore validation instead of reporting green for a rig that restore would refuse. The validator moves unchanged into a shared module; restore keeps calling it at the same point with the same messages, order and refusals. restore-check runs it once per rig on the snapshot that rig up --existing would choose: refusals show red, warnings alone show yellow, and an empty result is green, bounded to what was checked. It never captures or invents a snapshot.
When there is no usable current-occupant snapshot, restore-check reuses ordinary up's existing current-state rehydrate eligibility: an eligible stopped or partial rig keeps its down or partial status and the rig up --existing suggestion, with the restore inputs shown yellow as not inspected; an ineligible rig shows up's own blockers in red; a missing rig or a read error stays a named unknown for that rig only. In the overall verdict a known red now wins over an unrelated unknown (exit 1 before exit 2), while each rig's own status and the counts remain visible.
Composed rig status skips the added validation for rigs whose seats are all running and ready; explicit restore-check always runs it. Query counts per assessed rig are documented in the PR; the full snapshot history is still read for stopped rigs, which is follow-up work. P2 spec-root corrections and launch-record rig_id are out of scope.
Refs #130
An explicit CODEX_HOME was honored by daemon startup writers but not by every managed path. Launch, resume, fork and legacy commands, profile and capability probes, thread-id and context-usage readers, and plugin-cache discovery now all use the same selected home, including when a pane's shell rc sets a different value. An empty CODEX_HOME is treated as unset at the first startup read, so every consumer uses the default home; whitespace and relative values keep their existing startup refusal. The setup disclosure names the selected config path and its default.
This is opt-in: with CODEX_HOME unset, a second install still shares ~/.codex. Auth commands keep the caller's root, and no authentication, history or settings are copied or migrated.
Upgrade note: if your shell rc sets a CODEX_HOME different from the daemon's, align them (or unset the daemon's) before upgrading; existing seats otherwise resume under the daemon's home, where their earlier Codex thread isn't.
rig transcript printed the daemon's captured text with every empty line removed, collapsing paragraphs and blank lines inside code blocks. The renderer now prints every line except the trailing split sentinel, whose newline console.log already supplies, so leading, consecutive and interior blank lines survive and no extra final blank line is added. The near-empty-transcript hint still counts only non-empty lines. --json and --grep output are unchanged; the remote --host path shares the renderer and gets the same fix. A regression test covers leading, consecutive and interior blank lines, including inside a fenced code block.
Contributed by @rudycelekli.
rig chatroom history and rig chatroom wait now surface HTTP errors from the chat history API instead of hiding them.
- history: with --json, the body is printed as before and the exit code is 1. In plain mode, the error (or "Failed (HTTP n)") goes to stderr and the exit code is 1, instead of "No messages." with exit 0.
- wait: the first HTTP error is reported and the command exits 1, instead of polling until the timeout.
- Successful output is unchanged.
Compaction preparation prompts now describe what is actually happening.
- Manual requests name manual mode and make no threshold claim. They give the preparation deadline in UTC and the time remaining when the request was built (not guaranteed on receipt), and say that delivery, writing the full restore map and becoming idle share that deadline.
- Automatic requests say their 25-minute ceiling starts after preparation delivery returns, with no invented absolute time.
- The preparation_incomplete reason now covers the map and the idle wait, including expiry after a completed map.
- Timing values, the restore-map prerequisite and marker, guards, cancellation, expiry and disarming are unchanged.
Refs #610.
When OpenRig can't verify that a startup prompt was submitted, the warning now says why. "Unrecognized composer boundary" means the capture was available but the composer's marker or closing rule wasn't found. The existing extracted-text-mismatch wording is kept for real mismatches. A text-free reason label is added to the startup diagnostics.
- Applies at the initial and the after-retry observation.
- Wording and diagnostics only: the composer classifier, transport, guarded-Enter matching, verdicts, waits, retries and captures are unchanged.
- No prompt or reply text in the warning or the event fields.
Recording footer-shape metadata for unrecognized layouts is a possible follow-up, not part of this change.
Pod-aware launches now record which rig they created. The bootstrap run row gets rig_id on the pod-aware success path, as the legacy flat and all-attention paths already did, so a modern rig can be traced back to the spec it was launched from.
- Applies to completed and mixed (partial) pod-aware runs. Failed runs have no rig and are unchanged.
- Launch behavior, responses and side-effect order are unchanged. GET /api/bootstrap now shows rigId for these runs.
- No migration. Earlier runs stay without a rig_id.
Refs #130.
restore-check no longer reports red when a rig's spec isn't under <shared-docs>/rigs/<rig>. That folder is the only place it looks, nothing in the product writes there, and restore itself never reads the spec, so the red was false.
- A missing <root>/rigs/<rig> is now yellow "not checked", naming the path it looked for and saying no launch spec location could be established.
- A present spec stays green. A folder without rig.yaml stays yellow.
- No new status value, and every other check reaches the verdict unchanged. When this was the only finding, rig restore-check now exits 0 (restorable with caveats) instead of 1, per its documented exit contract.
- Restore, its refusals and the recovery plan are unchanged.
Refs #130 (its spec-location part).
Classic (non-managed) Claude launches resolve claude through the pane's interactive shell again, so a claude defined only as an rc alias or function starts, as it did before #618.
- For recognized POSIX pane shells (bash, zsh, sh, dash, ksh), the staged command is sourced in a subshell of the pane shell, and public seat metadata uses leading assignments. #618's metadata correction and its rig-only PATH entry are kept, and the variables don't leak into the pane shell.
- Every other or unreadable shell (fish, nushell, unknown, failed read) keeps #618's /bin/sh staging. An rc-only claude alias or function there stays unresolved, as at #618.
- Long commands stay protected: the typed invocation is about 115 bytes for a 4 KiB command, and the full command stays in a private 0600 file that is removed after use.
- Managed Claude and Codex launch paths are unchanged.
Bash only was executed (real tmux, offline recorder, native identity stubbed). Fish and nushell were checked through reported-shell selection, not native runs.
Refs #618.
Comment-only follow-up to #462: the asset route's comments now match the shipped headers. Every asset response carries nosniff. Rendered HTML gets a script-free CSP under the web UI's fail-closed rule. SVG is served as an attachment, so opening one directly downloads it, while <img> embedding is unaffected. The previous "advisory, not a gate" wording and the threat narrative are removed. No code changes.
Refs #462.
/api/files/asset responses now carry X-Content-Type-Options: nosniff.
- Rendered HTML (the ?render=1 view of .html files) is served with a Content-Security-Policy that runs no scripts, opens no connections and can't be framed. Inline styles and same-origin images still render.
- SVG files are served as attachments with a UTF-8 filename*, so opening one directly downloads it. <img> embedding is unaffected.
- Image, video and range replies are otherwise unchanged.
The route serves only the web UI. The transcript-search change from an earlier revision is not part of this PR.
When a wake to a parked owner is refused because the seat is at an interactive prompt, the outstanding work now reaches the existing orchestrator/operator escalation path, and nothing is typed into the blocked seat. One aggregate per episode carries the affected rows and owns their continuation. The original is not retried or exhausted by the generic ladder, and its waiting readback reports the delegated recovery. The alert retires when the prompt positively clears or the work closes.
- Clearing needs a newer state from an authoritative needs-input-capable observation. Default Codex trial hooks and generic sampling-only seats never report a blocked prompt, so they get no prompt alert yet.
- Shared operator seam: missing human bindings, unavailable registry reads and gateway dispatch refusals now stay unresolved and are re-resolved on the existing cadence, instead of exhausting as undeliverable. Unchanged unresolved decisions add no sends or notes, and old exhausted episodes stay closed. Aggregate bodies include member summaries. Accepted or deferred delivery stays gateway-owned.
- Robustness: prompt-pass faults are logged without stopping the existing ladder. New JSON-membership reads ignore malformed tags. An unreadable candidate or member holds only its own destination for that tick and can't retire an alert.
- Composes with the guard-refused retry path from #623: an open prompt recovery owns both retry and escalation.
Refs #81.
Health sources are read independently, so one source that fails or exceeds its limit no longer makes every health read fail. A failed source is reported as unavailable, with its name and reason. A bounded source is reported as partial, with its actual census. The other sources keep reporting, and a missing source is never shown as healthy or zero. Disabled detectors are skipped and leave no stale result.
- Attention reads the coverage of the same health read. It marks health unavailable or partial, names the affected source, and keeps the working sources' current items.
- The TUI keeps current items. During an outage it re-shows earlier items that depend on the unavailable source, under the named outage. A successful partial window replaces the previous one, and recovery clears the outage.
- No limit, threshold, policy, schema or collector changed.
Known limit: earlier items are retained under the overall health dependency, not per source, so during one source's outage an item another source has since dropped can stay visible, marked as from an incomplete feed.
Refs #613.
The built-in shared agent spec now names its core plugin as openrig-home:plugins/openrig-core, so it resolves under the daemon's configured OpenRig home (OPENRIG_HOME, else RIGGED_HOME, else ~/.openrig) instead of always under ~/.openrig.
- Default installs resolve to the same path as before.
- An instance with a non-default home now loads the plugin copy the daemon seeded there.
- A planner regression test loads the real built-in spec under a temporary home and checks the resolved path. The starter-library expectation follows the new spelling.
The openrig-home: form needs a daemon that supports it. The built-in spec ships with that daemon.
When the typing guard refuses a wake (the seat is protected from typed input, so OpenRig retains the nudge instead of typing it), an unclaimed baton keeps its retry path instead of sitting stranded until someone nudges it by hand.
- queue-recovery counts a retained: nudge attempt as newer evidence, like failed:, so a terminal recovery written before the refusal no longer hides the row.
- The wake ladder treats retained: as a retryable failed-mode wake only for pending, unclaimed batons. Claimed rows keep the previous classification, so the parked-owner path still owns them.
- Retries reuse the row's delivery id. The first retry re-probes the guard, later retries read back as retained, and the row then escalates through the existing rungs. Retry cadence, budget, attempt cap and exhaustion are unchanged. Nothing is typed into the guarded seat.
Refs #344.
The stuck sweep's custody detector now also covers seat-address closures. A row closed done or canceled with closure_reason handed_off_to toward a seat (not a qitem id), and with no successor carrying lineage to it, raises a custody finding instead of going unnoticed. Handed-off rows are skipped, because a transactional handoff always creates its successor.
- Scope: each tick scans done and canceled closures updated within a fixed 24-hour window, plus rows that still carry an open finding, so history from before an upgrade doesn't raise waking findings. There's no configuration key for the window.
- Successor detection requires direct lineage (handed_off_from or chain_of_record), so unrelated work for the same seat doesn't suppress a finding.
- The verification recipe uses rig queue list --destination.
Refs #318.
Seat launches re-assert OpenRig's own public variables after the user's shell startup files run, so a pane rc can no longer override OPENRIG_HOME, OPENRIG_URL, the node id, session name, runtime or occupant generation. Only an explicit allowlist is typed; credentials and provider keys keep their tmux environment channel, and an OPENRIG_URL carrying userinfo, a query or a fragment is left out of the typed line.
- PATH: one OpenRig-owned directory, $OPENRIG_HOME/run/seat-bin/<sha256 of the daemon's paired CLI path>, holding only a rig link to the daemon's own CLI, goes first; the rest of the user's PATH order is unchanged. Unexpected contents in that directory are preserved and the launch falls back.
- Codex: an npm-installed Codex and its env-node interpreter resolve on the capability-probe PATH; child tools get the user's PATH.
- Fail-open: if the paired CLI, the tmux environment read, the identity or the node check is unavailable, the launch uses the previous command with one fixed log line. No new refusal.
- Nushell panes keep the previous literal command.
Limits: non-managed Claude launches now run from a /bin/sh script, so `claude` must be an executable on PATH (the native installer's ~/.local/bin/claude is); a legacy install reached only through a shell alias or function is not resolved, fallback included. A stray entry in the seat-bin directory disables the correction for that install (it fails open). Not exercised on a real provider, tmux, macOS or Nushell session.
Reviewed independently: Claude source review and Codex executed review (red/green controls including the npm Codex interpreter case, concurrent alias creation and rc overrides), both MERGE-READY at this head; CI 8/8.
Refs #612. The remaining tmux commands that address a seat by name now use exact targets, completing the change begun for capture and probes. respawnPane, setRemainOnExit, isPaneDead, getPaneCursorPosition, readPaneLastActivity, setSessionOption and getSessionOption use the exact pane form (set-option and show-option take a target pane even for session-scoped options), and switchClient uses the exact session form. The seat-switch service qualifies its window lookup and its final switch target, so a session that disappears after the probes cannot switch a client to a prefix-named sibling; its public target is unchanged. Before this, a missing session could have a sibling respawned, read, re-optioned or switched to. Live names, tmux ids and explicit targets behave as before.
Refs #556. When no seat is rotating transcripts, the transcript capture stats report the idle ceiling as null, like the active interval, and rig ps --resources shows it as unavailable instead of 0 ms. The host resources reference explains the default 3-second idle ceiling: the 10-second freshness window minus the 1-second hint lookup, the 5-second capture result deadline and 1 second of timer spacing, never below the configured active interval. It makes no CPU or child-process lifetime promise.
Settings writes from the CLI config store and the daemon settings store keep the existing file usable when an update fails partway. Each store stages the new content beside the target and renames it into place, preserving owner, group and mode, so a failed staged write or rename (including ENOSPC or EIO) leaves the original byte-identical. An existing target is first opened for writing without truncation, so a read-only settings file is refused as before. Where the directory doesn't allow temporary entries, ownership, mode or rename (EACCES, EPERM, EBUSY), the update falls back to writing in place, as before, with that path's existing partial-write limit. instance-layout.md describes the behaviour, including that a hard-linked second name keeps referring to the previous file after an atomic replace.
Transcript capture adapts to seat activity, and rig ps can show host resource use. Active seats keep the configured poll cadence. Idle seats back off, but the next idle capture is scheduled from the last successful completion and capped by a ceiling derived from the shared 10-second freshness window, the hint lookup and a 5-second capture result deadline, so an idle seat's transcript stays fresh even when one capture is slow. A capture that fails or outlives its deadline counts as a failure, never refreshes freshness and is not written late, and captures for a seat cannot overlap. The existing capture_stale response is unchanged. rig ps --resources reports capture cadence and host resource figures.
Refs #612. tmux commands that address a seat by name now use tmux's exact target forms, so a session that has ended can no longer be resolved by tmux's prefix fallback to a sibling whose name starts the same way. Pane commands use =name: and session commands use =name, for capturePaneContent, capturePaneScreen, hasSessionEnv, startPipePane, stopPipePane, getPaneCommand, getPanePid, setWindowOption, resizeWindow, and the unguarded detach-client and kill-session in killSessionUnchecked. Before this, with the target gone, a capture read the sibling's screen, readiness and resume probes read the sibling's command and pid, window options and size were changed on the sibling, and an unguarded kill could kill the sibling. tmux ids (%N, @N, $N) and explicit targets containing ':' pass through unchanged, so the guarded kill still uses the immutable session id. listWindows, isPaneDead, getPaneCursorPosition, readPaneLastActivity, setSessionOption, getSessionOption, setRemainOnExit, respawnPane and switchClient are listed for follow-up.
rig compact-plan no longer treats an incomplete daemon inventory as a complete one. For a fleet plan, a rig whose node read fails is left out and reported in incompleteRigs with its HTTP status, invalid_inventory or request_failed; the plan is built from the rigs that read successfully, text output prints a partial-plan warning on stderr, JSON always carries incompleteRigs, and the exit code is 1 when anything is incomplete. A failed read for an explicit --rig, or of the process list, still fails with exit 2 and no plan.
AgentSpec plugin resource paths gain an openrig-home: form, resolved under the daemon's configured OpenRig home (OPENRIG_HOME, else the legacy RIGGED_HOME, else ~/.openrig) rather than the user's home directory, so a rig running under a non-default OpenRig home loads the plugin copy its daemon seeds. The suffix must be relative; an absolute suffix is refused, and the planning error reaches the instantiation result. Absolute, ~ and spec-relative paths are unchanged. The agent-spec reference documents the four path forms.
Refs #490. The minimal-schema closure-overdue dedup test now sets the claim to max(UTC day start, now minus 5 minutes), so the events written with datetime('now') always fall on the claim's UTC date. A text timestamp comparison or an unescaped LIKE match therefore fails the test on any date, not only on the day it was written. The architecture event-system doc's types.ts citations are updated to the RigEvent union's end at line 311 and RestoreNodeResult.status at line 473.
The installed-scenario CI job now also runs the transcript-reads-addressed-seat and capture-returns-addressed-seat stub scenarios once each, healthy only, after the six baton runs and through the same per-container block. A new verifier accepts only a clean PASS of the named scenario: one ledger record equal to the result, the scenario SHA-256, exit 0, and no error, seed or fault. The baton verifier is unchanged. run-pr-scenarios.sh gains --case transcript|capture, and a lost-baton mode with a passing-only case is refused. Planted failures for these scenarios stay as per-PR evidence and are not run in CI. The job name and required checks are unchanged; on this PR the job took 4m08s against a 3m42s baseline. test-layers.md and the CI README describe the new CI and local split.
When a fresh Claude seat's startup check cannot positively match its staged prompt, node.startup_ready now carries bounded diagnostics for each unverified startup send, and node.startup_failed carries them if a later step fails. Each record names the startup attempt, the send's order and source, the observation phase and what the single guarded retry did, and compares expected and observed text by normalized byte count and SHA-256 with the first differing byte offset and the marker and closing-rule positions in the capture. No prompt text or excerpt is stored, because startup actions and files are not classified for credentials. The full positive match before the one guarded Enter, the unverified result for ambiguous or unavailable observations, and the startup outcome are unchanged, and a failing diagnostic sink cannot change them. No extra capture or pane write is added.
Resolves#490. The daemon now produces qitem.closure_overdue: the stuck sweep records it once per claim episode for an overdue claimed item, with a closure-overdue transition by daemon@system, and a re-claim starts a new episode. Deduplication matches the exact qitemId with json_extract and compares timestamps with julianday on the path without queue_transitions. A failed overdue write is logged and does not drop the sweep finding. The overdue-claim finding shows the claimant's last note rather than the daemon's overdue note. mission_control.view_refreshed, which nothing produced, is retired from the declared events and its consumers, and the event-system and mission-control docs are updated.
Refs #81. The rig attention total in rig ps now counts a seat waiting at needs-input using the same arbitrated answer as the seat row. A positive needs-input count counts. An authoritative clear from needs-input chrome, lifecycle hooks or self-report does not count. When no authoritative source has answered, the hook store decides, as before, so a live permission request on a seat with hooks at trial or sampling only still counts. A stale hook no longer outweighs an authoritative clear. Display only: no delivery, refusal or notification behaviour changes.
The capture stub scenario's wording now matches what it tests. The member names overlap by prefix (impl and impl2, or dev-impl and dev-impl2 with the pod); the full seat names do not. The scenario catches a capture that resolves the seat by a prefix match on its member name and takes the first hit, or any resolver that returns the same pane for both seats. It does not exercise a prefix match on the full seat name. The text changes are in test-layers.md, the scenario header and the stub topology comment.
Adds a stub scenario for rig capture with two seats whose member names overlap by prefix (dev-impl and dev-impl2). Each seat's stub script prints its own token, and each expect step captures one full seat name through the shipped rig capture --json and requires that seat's token, so a capture that resolves the seat by member-name prefix or by first match fails at the intended step. A capture that returns both panes is not detected, because contains has no absence form. The scenario uses env.stub_scripts, so it runs locally in host mode; the installed-scenario CI job and container mode do not execute it, like the transcript scenario. test-layers.md now counts 15 scenarios and 7 topologies, separates the scenarios that run in CI from those run locally, and lists the capture row's remaining gaps.
The README's rig setup row now says why setup changes cmux's automation.socketControlMode: cmux defaults it to cmuxOnly, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to automation.
Managed Claude compaction could submit /compact before its preparation request had produced a restore map: automatic mode treated delivery as completion, and manual mode only waited for idle. Preparation is now bound to an attempt and occupant and waits for the exact map named in the prompt, published atomically with an attempt-specific completion marker that establishes identity, not map quality. Unfinished attempts end on expiry, cancellation or an observed policy disable, and a usage dip below the threshold keeps the same attempt and deadline. Ordinary messages stay available while preparation waits, and the final paste and Enter recheck the attempt and recipient. Automatic preparation waits up to 25 minutes on the existing polls; manual map and idle waits share the existing 120-second budget, and the CLI HTTP timeout stays 180 seconds. rig compact <session> --state shows the attempt and deadline with the usual terminal auth, --cancel ends preparation, and an explicit retry creates a fresh attempt. --skip-map skips only the artifact requirement for one manual request. Known pre-write failures retry a bounded number of times; uncertain delivery or compact results are not replayed. Cancellation is checked before each observation and after it returns, inside the single wait-for-idle deadline; it cannot interrupt a read or undo input already issued. An unprepared seat may now reach its context limit rather than compact automatically, and attempt state stays in memory, so a disarm does not survive a daemon restart.
Fixes#596. Ordinary Claude delivery now warns and proceeds when the only disagreement is between the stored conversation token and the historical launch argv, so a seat stays reachable after an in-process conversation reset. Neither token is promoted to verified identity, and the warning says the current conversation is unverified. This also applies to a settings-before or settings-after session-id mismatch, a plain wrong token and a lone mismatched launcher shim when token disagreement is the only objection; some of those can be a different conversation. Positive conflicts still refuse: a different native runtime, mixed named identities or incompatible live lineages, a changed process fingerprint, occupant, pane or send-time binding changes, and positive idle-shell evidence. Strict startup and resume selectors and hook precedence are unchanged, and restore can remain attention_required while an ordinary send proceeds with a warning. A change detected after paste can still leave one paste and block Enter.
When a send cannot tell what a seat is doing, the producer-link advisory now reports only what it observed. OPENRIG_RUNTIME is checked as a relay prerequisite alongside the URL and token. Names missing from the tmux session environment are reported as seat-env UNKNOWN rather than DOWN with a relaunch instruction, because a managed launch passes them through an env-prefixed command; the agent process environment is not read. Hook age is checked before recency, so an old prior-tenure hook reads as STALE with its generation stated. "OK" applies only to a recent hook from the live occupant's own generation; a recent hook from a prior or unverifiable generation says so and keeps the environment note. With no stored hook, the advisory no longer blames ingest and lists the possible causes instead. A node-inventory comment is corrected, and AgentActivityStore.freshnessMs becomes a public read-only field so the diagnostic uses the store's own window. Attribution, generation and freshness rules for decisions, the endpoint-file fallback and the send behaviour are unchanged, and checks remain presence-only.
waitForIdle used to fail on the first unknown activity reading, so rig send --wait-for-idle and the compaction stages refused idle Claude seats whose last hook was old or from a prior generation. An unknown reading is now retried like busy within the caller's one absolute deadline, and it never authorizes a send. No observation starts after the deadline, each awaited observation is raced against the remaining time, and a result that completes late is discarded rather than accepted (the read is abandoned, not cancelled). At expiry the result names the last state actually observed in time: target_activity_unknown or wait_for_idle_timeout. Positive needs-input still refuses at once, and the identity, binding and final-send checks still run before paste and Enter. Ordinary sends without a wait and the startup and restore probes are unchanged. The context monitor's serial post-compact stages can now wait up to their existing timeouts on unknown readings.
rig ps --full (and --verbose) clipped rig names over 24 characters and ran columns into the footnotes. With --full, each column now widens to its content's display width, measured with string-width so CJK, emoji and combining marks align; the default compact table keeps its previous total width, takes the new two-space column separators out of the fixed widths, and cuts by display width on grapheme boundaries with an ellipsis. A blank line now separates the table from its footnotes only when footnotes follow. JSON output is unchanged.
Adds the runtime dependency string-width ^8.3.0 (with get-east-asian-width, strip-ansi and ansi-regex; all MIT, no install scripts).
Fixes#590
Codex seats launched with OpenRig's plain workspace-write floor could not reach the local daemon, because Codex's sandbox defaults network access off. Before such a launch, OpenRig now runs one short-lived codex app-server with the seat's own executable, HOME, CODEX_HOME and cwd, reads config/read (with layers) and configRequirements/read, and adds -c sandbox_workspace_write.network_access=true to that launch only when Codex reports no explicit setting from any layer and no requirement pins it. Any explicit user, project, managed or requirements value wins.
The reader is reader-only (plugins and apps disabled), has a finite deadline, checks RPC results rather than exit codes, cleans up only its own descendants, and never blocks the daemon's event loop. Named profiles, legacy YOLO and full bypass are not read and their commands are unchanged. A deadline, RPC error, unknown shape, missing binary or unsupported selection adds nothing and the original launch proceeds, which may leave network off. Codex startup may write its own state files under CODEX_HOME, read its auth store and fetch managed policy; the read and the launch are not atomic. These are disclosed in the docs.
Refs #275
Checkpoint created_at has one-second precision, so getLatestCheckpoint and getCheckpointsForNode returned an arbitrary row when several checkpoints shared a second. They now order by created_at and then rowid (latest insert last for the list, first for latest), the same tie-break the snapshot and package repositories already use.
Two daemon e2e tests (seat-handover-model-fidelity and seat-lifecycle-set-model-resume) used to launch a real Codex whenever tmux, codex and a Codex auth file happened to exist, so an ordinary package test run could spend the signed-in account's quota. Each preflight now returns false first unless OPENRIG_E2E_REAL_CODEX is exactly 1, before any tmux, codex or auth probe, and afterAll starts nothing when the run did not opt in. With the variable unset, 0 or true, both files are discovered and skipped; with 1, the original checks and assertions run unchanged. Test files only; no product code.
Thanks to @1solomonwakhungu for the fix, carried over from their fork.
A fresh Claude seat could be reported ready while its startup prompt was still sitting unsent in the composer, so a person had to press Enter. After the existing paste and Enter, startup now captures the pane once (200 lines) and, only when the whole whitespace-normalized expected body is positively matched inside a recognized composer, sends one guarded submit-only Enter through SessionTransport with the existing target and lease checks, then observes once more. There is no re-paste, no loop and at most one extra Enter.
A prompt still staged after that is not a startup failure: StartupResult and node.startup_ready carry submission.status=staged with the warning 'Startup prompt still staged in <pane>; press Enter in that pane', and the seat keeps running. A different body sharing the header, stale echo, partial or opaque renders, unknown layouts and unavailable captures get no automatic retry and are reported as unverified. The challenge-only prompt stays best-effort. Other submit-only callers keep their 50-line capture and their unchanged matcher.
A malformed JSON request body used to surface as a bare 500 from any daemon route. A middleware registered first now tracks SyntaxErrors thrown by c.req.json() for the current request, and the app error handler maps only those to 400 invalid_json. Every other error follows Hono's default path unchanged: errors carrying getResponse (such as the delivery guard's typed 409 refusals) keep their status, body and headers, and anything else, including a SyntaxError from stored data, stays a logged 500. Routes that catch their own bad body keep their own response, and the existing sub-app error handlers are unchanged.
A queue handoff or handoff-and-complete addressed to the receiving daemon's own exact host id (case-sensitive, never a null self id) now takes the ordinary local path instead of forwarding to itself over HTTP. Cross-host forwarding to any other host is the byte-identical call.
Upgrade compatibility: when a self-addressed retry finds the deterministic successor that a pre-upgrade self-forward already committed, it runs the receiving create in process (the same input checks via a shared createInputRefusal, the primary-key absorb with qitem_id_reuse for a different sender, and the qitem_body_not_saved warning for a changed body) and then the existing cross-host close, so a retry gives 2 rows and 1 send. Refusals on that path return the receiver's 4xx directly (409 qitem_id_reuse, 400 body is required or unknown_target_repo) instead of the old 502 wrapper, so the CLI exits 1 instead of 2 for them; both are non-zero. The /create route's own behaviour is unchanged. coordination-primitive.md is updated.
Rewrites the claude-compaction-restore skill around a restore map. Before compacting, a seat writes a short summary, how things connect, and a ranked reading list in its seat folder, and publishes the summary as its seat recap. After compacting, it re-enters the world, reads its newest map, and reads down the ranked list to its class's tier: about 100k of real context for most seats and about 200k for orchestrators, planners and leads, scaled for smaller windows. Restore packet files and the session JSONL are lookups, not reading lists. The read-depth audit counts harness re-attached files as not yet read.
The daemon's post-compaction restore and audit prompts are aligned with the skill: the restore prompt says to read the newest restore map and use the packet as a lookup, the JSONL rebuild fallback applies only when there is no map, and the audit prompt names the required set instead of asking for every file. The previous skill text is kept as reference/SKILL-v1.md, marked superseded. openrig-core goes from 0.1.2 to 0.1.3 so upgraded installs receive the change; edge digests are regenerated.
A post-compaction context profile used to fail entirely when the seat had no RECAP.md, so a freshly compacted seat got none of its world atoms. In the post-compaction situation only, a seat recap that is genuinely absent (lstat ENOENT under an existing seat directory) is now skipped: the response lists it in skipped and adds the warning 'no seat recap for <seat>@<rig>; read your newest restore map'. A dangling link, a missing seat directory, an unconfigured root and every other atom still fail, and a handover with no recap still fails loud. The recap piece now carries its file write time (writtenAt), shown in the CLI framing header as 'written <ISO>', so an old recap is visible as old. rig context profile and rig walk --through-profile print profile warnings to stderr; stdout piece text is unchanged.
provisionClaudeState used to rewrite the whole Claude state file on every startup delivery, including after launch, even when nothing changed. Since #592, a classic seat can provision the daemon-selected CLAUDE_CONFIG_DIR/.claude.json, which live Claude processes and other seats share. A changed flag now decides the write: a trust key is updated only when hasTrustDialogAccepted is not true, hasCompletedOnboarding only when it is not true, and with neither changed the function returns before mkdirp and writeFile. A real change still writes once, preserving every other field. Malformed-state preservation, the managed and two-home classic paths and the launch command are unchanged. Temp-file-plus-rename is not added, because the adapter fs interface has no rename; the remaining writes keep the existing cross-process risk.
Refs #592
A classic (no permissionMode) Claude seat types claude into its pane shell, which inherits the tmux server environment, so the bootstrap cannot see which config home the child reads. When the daemon selects CLAUDE_CONFIG_DIR, bootstrap now provisions both HOME/.claude.json and the selected directory's .claude.json (de-duplicated when equal), setting only hasCompletedOnboarding and the workspace trust flag and preserving every other field. Malformed state in one file is preserved with a warning and does not stop the other. The managed explicit-mode path still writes only its selected home, and the launch command is unchanged. This replaces the exactly-one-file rule from #565 for classic seats.
Known follow-up: the state write is unconditional and not atomic, which was already true for HOME/.claude.json; skipping unchanged writes is tracked separately.
Recover stale same-name rig generations. A failed rig up could leave an archived same-name duplicate that blocked or confused later runs. rig up now prefers an active same-name rig and falls back to a sole archived one; tearing down an archived duplicate never probes or kills a session owned by a live rig, and shared guidance files are protected by physical identity, recomputed at each cleanup step. Teardown no longer treats an unreachable tmux as proof a session is absent: it keeps the rig and reports the cause. Fixes#487.
Publish complete, round-trippable agent images. Agent image install wrote its discovery manifest before its members, so an interrupted install could be discovered as a complete image, and unquoted notes could lose scalar identity on round trip. Members are now written before the manifest, string fields are written so they round-trip exactly, and a creator that wins a race after the existing-target check gets the existing typed image_referenced refusal instead of overwriting another install.
Distinguish unreadable profile sources from dangling symlinks. Profile loading reported any read failure on a symlinked source as a dangling symlink. It now reports a dangling symlink only when the path is a symlink and its target is missing, and otherwise reports the actual read error.
Stage local context-pack installs before publishing them. A local rig context add copied straight into the library, so a failed copy could leave a partial pack. It now copies into a temporary folder inside the library root and renames it into place, removing the staging folder on failure; an existing destination is still refused before anything is written.
Derive burn rates from measured intervals. Usage burn-rate computation treated a missing counter as zero, producing a fake reset followed by a whole-total burn. Samples with missing counters are now excluded before deltas are computed, too few measured samples report insufficient_samples instead of a rate, and the time window's upper bound is inclusive.
Report failed restore discovery in rig start. A non-200 from the rig summary read during rig start was treated as the list of rigs to restore. It now goes to the existing error path, which prints the HTTP status and exits 1; daemon and kernel startup before discovery are unchanged.
Report failed library inventory reads instead of listing them as empty. plugin list, agent-image list, context list and specs ls treated any daemon response as the inventory, so a 401 or 503 error object read as an empty or garbled list. They now raise on a non-200 status, and each command's existing error path prints the message and exits 1.
Resolve legally indented Markdown headings in context lookups. CommonMark allows up to three leading spaces before an ATX heading, but heading-address lookups only matched headings at column 0. Indented headings now resolve; lines inside fenced code blocks are still ignored, and existing lookups are unchanged.
Ensure the transcript tail reader makes progress on damaged UTF-8. The backward chunked tail reader skipped any number of leading UTF-8 continuation bytes and pushed them back, so a chunk of continuation bytes, or a file starting with one, re-read the same range forever, blocking the synchronous read. The skip is now capped at three bytes and nothing is pushed back at offset 0, so every pass makes progress; valid UTF-8 output is unchanged.
Bound the TUI activity stream's opening wait and reconnect after temporary opening failures. Opening the daemon activity stream had no header deadline, and any fetch error turned live updates off for the rest of the TUI process. Opening now has a 5 s header deadline; timeouts, network errors and aborts reject into the existing drop, backoff (1 s doubling to 30 s) and reconnect loop, while definitive non-OK or non-event-stream answers still stop retrying. An established stream is not bound by the opening deadline.
Reject @ in pod and member session-name components. A pod or member ID containing @ produced a session name whose pod/member part could not be separated from the rig name. Preflight, member creation and launch now refuse such pod and member names with a message naming the field; rig names may still contain @. Schema validation is unchanged, and docs/reference/rig-spec.md now explains that rig spec validate passes such a spec while preflight and import refuse it. Fixes#472.
Warn about stale deferred role hook references. A role's hooks: entry that names a hook absent from exports.hooks used to pass validation silently and drop out of the plan. Package validation and planning now report it as a warning naming the role and the reference; the manifest stays valid, because hooks in the package path are deferred and never installed, so validate, plan, install, bootstrap and bundle flows are unaffected. Refs #477.
Keep read-only bundle sources writable in staging. Pod bundle staging copied each file with its source mode, so a read-only (0444/0555) agent.yaml or script produced a read-only staged copy, and a later rewrite of that vendored path (for example import-ref rewriting) failed with EACCES. Staged copies now get the source mode plus owner read/write. Execute and group/other bits are preserved, source files are never modified, and archived modes are unchanged because the portable tar writer already normalizes them. Fixes#574.
Treat a Claude launcher shim chain as one runtime for delivery identity. A Claude seat started through a launcher shim was refused for every rig send, because delivery inspected only a single process. Delivery now reads the whole shim chain: the session is verified when the real Claude process names the expected session, a shim token that agrees cannot certify an unknown child (unknown still delivers with a warning), and any explicitly different session token, whether on the process or a lone shim, is refused as a conflict. The strict identity check used at startup, resume and restore is unchanged; --settings parsing applies to delivery only. Fixes#563.
Test-only: pin tmux has-session's exact-match target (=<name>) in the adapter tests, so a future refactor cannot silently fall back to prefix matching. Also covers the absence classification for a missing exact session. No production code changes.
The workflow spec scanner's same-second unchanged check hashed raw file bytes while the cache stores a UTF-8 text digest, so a file that is not clean UTF-8 never matched and was re-parsed on every scan. Hash the decoded text the same way the cache does. Clean UTF-8 files produce the same digest as before. Adds a scanner test with a non-UTF-8 byte pinned into the cached second. Refs #445.
Re-verifies six as-built pages against b6d37bfa after recent merges: moved source anchors, the local spec validation helper, 93 migration files ending at 094, footprint and test-file counts. Docs only.
Re-verifies and restamps 19 as-built pages against one source commit (25412287): moved anchors, counts and named-Enter mentions updated, and portable grep in the README inventory commands. arteries.md, test-layers.md and the five web UI pages keep their own verification stamps. Docs only.
Adds migration 094 with two partial indexes so the latest usage sample lookups (context and window lanes) use an index search instead of a temporary sort. Query text and insertion-order deduplication are unchanged. Cost: one extra index entry per sample and a one-time index build on upgrade. Based on the query-plan finding in korallis/agent-stack#137. Refs #161.
Advances the bundled openrig-core plugin to 0.1.2 so upgraded installs receive every bundled hook and skill change made since 0.6.4; with equal versions, version-preserving vendoring kept the old installed files, so those changes reached fresh installs only. Removed bundle files are not pruned, existing seats and projections do not hot-reload, and later plugin content changes after 0.1.2 ships need their own version decision.
rig spec validate now validates locally without a running daemon, using the same validator as the daemon HTTP /validate route; help says so, and rig spec preflight still uses the daemon. Unexpected validator errors are reported as internal errors, never as an invalid spec, and the HTTP route keeps its existing status codes. Docs and help only otherwise.
Adds an optional orientation: role marker for per-seat role startup files. Queue whoami returns the calling seat's marked role entries, and the refocus hook renders them within its existing budget. The three kernel specs stop using a shared role resource. Failures read as unknown rather than missing. An unknown orientation value is rejected because the field selects role bindings. This proves emitted instructions and paths offline only; whether a native model retains its role is unverified. Refs #64.
When a Claude seat uses an explicitly selected CLAUDE_CONFIG_DIR, OpenRig now writes its onboarding and workspace-trust bootstrap to that selected home, so the seat is not stranded at first-run screens. The classic launch (permissionMode omitted) keeps its existing home; a binding without a working directory uses the same stored-node selection as the launch; malformed existing state is preserved with a warning. The rig setup and README disclosures now name the right file. Native sign-in and Keychain behaviour are not verified. Refs #154.
Seats now receive the daemon's USER and LOGNAME at launch and on handover successors, including Pi and OMP runners. Nothing is derived when the daemon lacks them, and provider auth and HOME policy are unchanged. Whether this resolves the macOS Keychain sign-in symptom reported on issue 154 is not verified. Refs #154.
Refreshes the CLI reference with a command inventory derived from the registered command tree, plus the workflow-runtime, living-notes-review, content-surfaces and frontmatter-schema as-built modules against the current source. Docs only.
Submits with tmux's named Enter key instead of C-m at every submit site (shared transport, startup adapters, claim, handover, startup orchestrator), so terminals that encode C-m differently from Enter still submit. Message text is still sent literally; only the submit key changes. Thanks to @Totopo27.
Refreshes the as-built packaging/bootstrap/bundles and plugin/agent-image/context-pack/compaction maps against the current source, including sibling routing after bundle install, version-preserving plugin vendoring and generation-scoped compaction continuation. Docs only.
Refreshes the as-built README module index, codemap and architecture navigation page against the current source. Web UI pages are explicitly marked as not reverified by this update; each module keeps its own verification stamp. Docs only.
Adds a stub scenario proving that each of two seats exposes its own scripted output through the shipped rig transcript --tail 200 --json path, with a planted content-omission control that fails at the intended assertion. Updates the test-layers help-wanted map to show transcript as partially covered. Test and docs only; no production change.
Manual clear (rig seat clear-attention) now recognises a native Claude process behind its shell wrapper. It carries the saved resume token when one exists, and with no saved token it accepts stable positive runtime evidence for the current pane, which proves occupancy, not conversation identity. Strict restore and exact-token checks are unchanged, a wrong saved token never falls back, and periodic sweeps keep using one batched tmux read. Refs #197.
The execution view's git checks now run as awaited asynchronous calls, so a slow git command no longer blocks the daemon's other requests (health, queue, other views). The 5-second per-call timeout and the merged, unknown and N/A results are unchanged; SQL views stay synchronous.
The TUI's proof view now shows the current judgment's evidence first and lists every retained proof drop, including superseded rounds, under 'Retained proof drops — all rounds'. Only an explicit previous chain marks a judgment as corrected; no proof history is changed.
For a selected project, the execution view's daemon-adoption ("live") rung now reads N/A with an explicit basis, because that rung describes the OpenRig daemon's own source, not project deployment. The unscoped view keeps its daemon lineage rules, and a missing git object never establishes N/A.
Managed Claude restores recognise a headerless ready prompt, including bypass mode, only when the exact launched process and saved conversation identity agree, and readiness checks reuse that proof. If native identity disappears or stored resume metadata conflicts during the final startup/join checks, restore reports attention consistently; the final join updates readiness only on the exact still-current running session, preserving protected metadata and replacement sessions. Component and adapter tests with synthetic native observations; installed Claude Code behaviour is not verified here. Refs #86.
Transcript rotation now keeps each distinct structural boundary marker once instead of re-promoting every matching line, so an unchanged pane no longer grows its transcript file on every capture tick. Captured scrollback is left untouched.
Rig teardown now stops a seat's transcript capture only after its tmux session is confirmed terminated or already gone. When a kill fails and the seat is still alive, its transcript keeps updating; deletion is still blocked by kill failures as before.
New tmux seats no longer inherit the daemon's resolved transcript line-count and poll-interval values as overrides: those two variables start empty in each new session, so a seat's config.json takes effect at the next daemon restart. Explicit seat values still win, and running sessions and the daemon's own capture settings are unchanged. Refs #161.
Rig teardown now reports a Compose teardown that returns failure, in both the already-stopped and live branches, as a 'Service teardown warning'. Teardown and deletion still proceed; the CLI shows the warning and exits 2, where it used to report success.
The TUI now cancels the body of a rejected activity-stream response and of a page read that fails with 5xx, so a response the server keeps open no longer holds its connection. Accepted streams and returned results are unchanged.
The TUI's live-event stream parser now follows standard SSE framing: CRLF, bare CR and multi-line data fields are handled, and a frame larger than 1 MiB is dropped and the stream reconnects. The daemon's current single-line notifications parse exactly as before.
Only a primary-button press now activates TUI and startup controls. Mouse wheel, middle and right presses over a control no longer activate it; wheel scrolling in the normal view and keyboard input are unchanged.
An out-of-range window_hours on the usage top route now returns a 400 explaining the supported range, instead of a 500 from an invalid date. Normal windows take the same path as before.
Periodic snapshot intervals longer than the native timer limit (about 24.8 days), such as monthly, no longer overflow and fire about every millisecond. Long intervals are now scheduled in chunks within the timer limit, and shorter intervals keep their existing timer.
Closes#547.
Automatic compaction no longer acts on a stale context sample. A high reading older than the freshness window sends nothing; a fresh reading exactly at the threshold still sends the preparation and then /compact; and a pending restore stage pauses while the sample is stale and resumes the same stage when it is fresh again. A sample timestamped in the future is not treated as fresh.
Closes#463.
Slack inbound dedup is now scoped by channel: the in-flight guard, the durable seen mark and the dead-letter skip all use channel plus timestamp, so identical timestamps in two channels are no longer treated as one event. Attachment files carry a per-message identity digest, so same-timestamp uploads with the same filename in different channels keep separate bytes. Queue item IDs are derived as before.
Closes#450.
A watchdog shared by several parked rows now survives the park transitions of the row that created its timer. Only rows still parked whose latest unfired wake is this job keep it alive; after a re-park, retirement and backoff follow the current owner. A failed delivery from a shared repeating timer keeps its retry instead of being consumed, and successful delivery and same-job reattachment behave as before.
Pod bundle assembly now contains rig files by their resolved directory boundary. A culture, docs or startup file that is a symlink resolving outside the selected rig folder (including a sibling folder whose name only shares the rig's prefix) is refused, with a message to copy the file into the rig. Links that stay inside the rig and a symlinked rig root still bundle, and external agent references keep their existing behaviour.
rig gateway human remove no longer treats a failed or malformed queue read as an empty board. An HTTP error reply or a non-array body from the queue list now takes the command's existing unchecked-board path and refuses, with or without --force, as connection failures already did; --force still archives despite known in-flight items and records each one. A healthy empty board still removes as before.
Closes#529.
A subscriber's nested emit no longer reorders or duplicates live events: nested notifications and envelope drains wait until every current subscriber has received the parent event, then the existing durable-row drain delivers children in order. A throwing subscriber is still isolated, and an ordinary outer emit still drains before returning.
Closes#526.
Readiness polling during seat start and handover successor launch now uses the whole configured allowance: the backoff is capped at the time remaining, and a final probe runs at the deadline, instead of giving up at the 15-second backoff boundary inside a 30-second window. Initial delay, cap and default timeout are unchanged; an unknown or false probe still never counts as ready, and a late attention result is now classified as attention.
Closes#525.
Paths under a directory whose name starts with dots (for example ..cache) now count as inside their root, in launch cwd resolution, rig up's path-form spec handling and restore-check evidence. A real parent traversal (..) and a sibling that only shares the root's name prefix still count as outside.
As a result, the existing rule for a launch cwd inside the OpenRig installation now also applies to dot-prefixed folders there when no cwd override is given; an explicit --cwd still works as before.
Closes#494.
Mission Control reports an unknown CLI version as unknown instead of a hard-coded 0.2.0, so the fleet view no longer flags every rig as running a stale CLI. A rig counts as stale only when a known version is reported as outdated; field support and version age are tracked separately, and the fleet views gain an unknown-capability count. No new lookup runs at startup.
Closes#488.
Workspace paths whose directory names start with dots (for example ..cache) are now recognised as inside their workspace root, in both the workspace resolver and the workspace doctor. Paths that really sit outside, including a sibling whose name only shares the root's prefix, still count as outside.
Closes#458.
When a workflow file is deleted or renamed, the spec cache now keeps every version that an unfinished workflow (active, waiting or failed) still uses, instead of deleting it. Kept versions stay out of the library and discovery, and a later scan removes them once no unfinished work needs them. A rename moves the cached version to its new path.
A parse error at a kept version's path is recorded as a separate diagnostic (named by its full path if the basename is taken) and never rewrites the kept version, and one file's diagnostic failure no longer stops the rest of the folder scan. Versions already removed on existing installs are not restored, and a version blanked by the earlier diagnostic writer is still not readable by name and version.
Refs #511.
The refocus hook spends fewer rig calls per trace so it fits its time budget. Roots come from one rig config read, made only when they are missing; the seat is taken from the canonical session name when its folder exists. The config read gets only the time left in a shared budget that reserves the configured-content lookup, and is skipped when little is left. No timeout value changes.
Discovery keeps literal session names that start with "=" exact: window and pane lookups use tmux's exact-target form, so sessions named "lit" and "=lit" each resolve to their own panes. The reported session name stays the raw name.
Closes#492.
Daemon-side endpoint URLs now bracket IPv6 hosts through one shared formatter: the crash-cart health and guard checks, the startup-proof request, the activity endpoint, and the Pi and stub runners. IPv4, DNS and already-bracketed hosts keep their form; scoped and malformed hosts stay rejected.
Closes#493.
End active and late-opening SSE responses during shutdown while allowing ordinary requests the existing drain budget. Preserve timed-out receipts when connections do not finish, and update startup tracking tests. Closes#166.
Forward the managed Claude launch terminal and locale values from the receiving pane while preserving the isolated environment and capability-query boundary.
Skip the unclaimed sweep for pending asks whose current human-notification episode has a posted receipt. Keep the original ask visible and preserve other recovery paths. Existing receipt-trust semantics remain unchanged. Refs #514.
Recording a workflow spec parse error no longer rewrites an existing cached version; it updates or adds a separate error row. Repairing the file clears only diagnostic-only rows; error rows that still hold a stored spec are kept so the old version stays recoverable. A file restored to its cached bytes is skipped again on the next scan. Refs #503.
When a rig command cannot reach the daemon because the machine blocked the connection (EPERM or EACCES, for example a Codex sandbox), the CLI now says so, keeps the OS cause code, and points to the offline help section instead of telling the user to start a daemon that may be running. A stopped daemon still gets the start advice. Refs #275.
A Claude seat whose cwd is a shared repository no longer replaces that repository's own statusLine command. Only commands OpenRig itself wrote are refreshed; anything else, including multi-line commands, is left as the user wrote it. Refs #421.
Resume-token capture, the refresher's null-fill and its periodic re-stamp ignore a status-line sidecar sampled before the Claude process now running in the seat's pane started, so a seat cannot record or keep refreshing an earlier session's id. Unknown timing captures as before. Stored tokens are not checked, cleared or repaired.
Adds two behavioural stub scenarios to the library: rig down stops every seat of a rig, and rig send renders the message in the addressed pane (rendered text only). The test-layers help-wanted list is updated, including the runner's current limits.
Seats can declare a reasoning effort for Claude Code and Codex. It is stored on the seat (migration 092) and survives restore, fresh relaunch, handover and set-model; removing it clears it; a first-start retry with effort is no longer refused. Seats without effort behave exactly as before.
rig queue create allocates its ID before sending and, if the outcome is unknown, prints that ID with how to check for the row and retry safely with the same ID and payload; a retry with the same ID but a different body returns the original row with a warning that the new body was not saved. A stopped daemon gets the rig daemon status / start hint.
The as-built daemon-core module is regenerated against current source: file and route counts, migrations, MCP tools and every code anchor match the stamped commit.
docs/reference/developing.md says every pull request runs the UI package tests as a required check, while local npm test leaves them out (run npm run test:ui); the portability report is the advisory check.
The shipped openrig-operating-model composer reads topology.root, the configuration key the CLI supports, so its roots and up commands resolve the topology tree without an environment override.
The refocus hook names why a seat's current work could not be resolved, and falls back to the work root only when the seat holds no typed in-progress work; a failed lookup is reported as unknown.
Runtime version checks run from the current working directory when it still exists and fall back to the filesystem root when it has been removed, so preflight no longer reports a missing install for a healthy Pi or OMP. Covers rig up preflight, import and bootstrap planning.
tmux session lookups are exact: a session whose name literally starts with '=' resolves to its own pane, so removing it kills the right pane and leaves a plain-named neighbour alone.
coordination-primitive, mission-control, workspace-primitive and
lifecycle-snapshot-restore are checked at e6a391b6: every cited path, symbol,
line and count, with each count beside the command that produces it. Corrects
the qitem id format, cross-host closure targets, closure reasons and outbox
states; the mission-control verbs, web route and notification defaults; the
workspace, missions and projects routes and workspace_json writers; and the
restore outcomes, session selection and restore-check shape. Removes
citations of the superseded architecture.md.
Co-authored-by: dev50-planner <[email protected]>
Adds a Pi section to rig-spec.md: each seat's agent directory under
$OPENRIG_HOME/state/pi/<session>/agent (set as PI_CODING_AGENT_DIR), where
Pi reads an operator-authored models.json, and the environment the Pi
process receives, as the runner builds it.
Co-authored-by: dev50-planner <[email protected]>
* docs: correct developer docs, the capability page and known problems against source
Checked against source at e6a391b6:
- developing.md: pull requests run .github/workflows/tests.yml; test:repo
also builds the daemon and checks context-pack generation.
- rig-spec.md: lists the stub runtime and what it is.
- The onboarding capability page: execution view --project, the three
first-project recipes, per-seat permission and typing-guard verbs, the
rig policy permissions namespace, and the web UI being off by default.
- help.md known problems: matched to the open issues and to what main fixes.
- test-system README, scripts README, ROUTING and evals README: match the
scenario runner, stub launch scripts and eval cases as they exist.
* docs(test-system): say the scenario runner has no proof binding
rig proof show exists. The proof surface is reserved because no scenario
surface reads it (readSurface throws UnboundSurfaceError) and the validator
rejects it (RESERVED_SURFACES).
---------
Co-authored-by: dev50-planner <[email protected]>
adapters-and-runtimes, agent-spec-and-startup, architecture-rules-and-event-system
and transport-and-transcripts are checked at 264fade9: every cited path, symbol,
line and count, with each count beside the command that produces it. Corrects
the runtime adapter set, startup and restore states, the RigEvent union, the
transcript capture mechanism, the chat and send behaviour and the MCP tool count,
and removes citations of the superseded architecture.md.
Co-authored-by: dev50-planner <[email protected]>
* fix(restore): inspect current queue and hook stores (#130)
* fix(restore-check): match the exact projected Claude hook command
* fix(daemon): retain caveats for unusable activity hooks
Both Origin checks took the hostname from the Host header with
`split(":")[0]`. For a bracketed IPv6 authority like `[fd7a::1]:7433` that
yields `[fd7a`, so the same-host comparison could never match and the web UI
opened at a non-loopback IPv6 address had its own requests refused.
Add `hostHeaderHostname()` next to the /api guard and use it in both places.
It parses the header with URL rules, matching the normalization
`new URL(origin).hostname` already applies to the Origin side, so brackets,
case and zero compression all compare equal. Values that are not a bare
host[:port] (userinfo, a path, a query or a fragment) return "", which never
matches an Origin, so a crafted Host cannot widen the same-host comparison.
Fixes#405
Co-authored-by: Claude Opus 5 <[email protected]>
"invalidates proof sources when only mission status changes" starts
watchProofSources and writes the manifest in the same tick. On macOS, fs.watch
arms its recursive FSEvents stream asynchronously, so a write made before the
stream is live is never reported. The test then saw zero events for the whole
5-second window, which failed intermittently on loaded CI runners.
Rewrite the unchanged draft bytes until the watcher reports. The proof basis
changes once, so the test still asserts exactly one proof.sources_changed
event and one invalidation. Product behaviour is unchanged; the watcher's
quiet refresh already repairs a missed early event.
Claude-Session: https://claude.ai/code/session_0111rhBDUYTL2n3Dbr2HDJf1
Co-authored-by: v-openrig-build <[email protected]>
* fix(tmux): detach seat views before destroying the session
An operator detach-on-destroy off switches a herdr attach onto another
live session when that seat is killed. Pin the option on fresh sessions
and detach clients before kill-session. An unexpected detach failure
stops that session's kill instead of being reported as success.
* test(tmux): expect detach-client before kill on the argv path
The #167 argv sequence test still listed only kill-session, so daemon package tests failed after detach-first teardown.
* fix(restore): retain inconclusive legacy Claude launches (#346)
Co-authored-by: v-openrig-build <[email protected]>
(cherry picked from commit eef01465a3)
* fix(daemon): check target name and browser origin once on /api (#358)
The daemon now checks every /api request once, before any route or cross-host read-through runs, including WebSocket upgrades.
- Target names: localhost, IP literals, this machine's own names and its own MagicDNS name are accepted without setup; other names are refused with a remedy (OPENRIG_ALLOWED_HOSTS, or a configured bearer token, which waives only the name check).
- Browser origins: when present, an Origin must be the built-in UI's own origin (with the web UI enabled) or listed in OPENRIG_ALLOWED_ORIGINS; a token never overrides an origin refusal.
- Host and Origin are read from the raw request headers, so duplicate or empty values are refused rather than collapsed.
- Refusals are a 403 with a one-sentence remedy and code; rig ps and the TUI now show it. Refusal logging is sanitized, bounded and deduplicated.
- Route-level authorization is unchanged.
Adapted for main: the existing /api Origin guard (apiOriginProtection) and the
terminal WebSocket Origin check stay in place. The boundary is mounted before the
Origin guard, so its refusal codes and remedies are what callers see; the terminal
route and its tests are unchanged from main.
(cherry picked from commit 7d77d73b9c)
* docs: add browser access reference (#372)
Adds docs/reference/browser-access.md (which addresses and browser pages the
daemon accepts, the two allowances, refusal remedies and limits) and links it
from the help guide's daemon-access section.
Co-authored-by: dev50-planner <[email protected]>
(cherry picked from commit a3cc6c8a10)
* test(daemon): expect the browser boundary's refusal from the real app
On the real daemon app the /api browser boundary now runs before the Origin
guard, so a cross-origin browser request is refused with the boundary's code
(browser_origin_refused) and remedy. The status stays 403; the middleware-level
cases of the Origin guard are unchanged.
* docs(reference): name the allowed origin the web UI needs at other names
On main the terminal WebSocket check and the /api Origin guard stay in place
alongside the browser boundary, so the web UI opened at a non-loopback name or
address needs that page's exact origin in OPENRIG_ALLOWED_ORIGINS.
---------
Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: dev50-planner <[email protected]>
run-scenarios.mjs builds the scaffold environment from HOME, PATH and TERM
only, so an ambient TMUX and daemon variables are dropped and running from
inside tmux is safe. ARCHITECTURE.md and the runner's header said it refuses
to start inside tmux.
Co-authored-by: dev50-planner <[email protected]>
The topology skill named rig expand four times and rig grow never, and
openrig-user only showed fragment-based additions. Point simple additions
(default agent spec, one runtime, one cwd, optionally a new pod) at rig grow,
and say when a fragment with rig expand or rig add is still needed.
openrig-user also documented rig add with the wrong arguments and a
rig add-member alias that does not exist; both are corrected.
Co-authored-by: OpenRig contributors <[email protected]>
* fix(daemon): evaluate the busiest ceremony families past the family limit
When more than 200 handoff families had queue activity in the observation
window, the passive ceremony source threw health_passive_family_limit. Health
reads combine all sources, so the whole projection failed: `rig health` and
the TUI HEALTH view returned an error instead of any finding, including
context pressure, and the scheduled evaluation recorded only that error.
Past the limit the source now evaluates the 200 families with the most queue
transitions in the window, breaking ties by lineage ID. A family qualifies by
its transition count, so this keeps every family that could qualify unless
more than 200 do. Families at or below the limit are evaluated exactly as
before.
The result says it is partial. The health list and `rig health policy`
(engine.lastEvaluation.coverage) report the source's total, evaluated and
omitted counts, the limit and the order. `rig health` and the TUI HEALTH view
mark it PARTIAL. Omitted families were not evaluated and are never presented
as healthy.
* fix(daemon): report the scheduled evaluation's own coverage
The scheduled evaluation read the passive source's latest coverage after the
whole evaluation finished. A health read served while it awaited delivery
replaced that coverage, so the evaluation reported another read's counts.
When diagnosis policy is disabled, no read happens, and it reported coverage
left by an earlier request.
The evaluation now snapshots coverage right after its own read and returns it
with the result; a skipped evaluation returns null. The TUI summary line puts
PARTIAL before the variable-length finding summary, so a narrow line cannot
truncate it away.
---------
Co-authored-by: OpenRig contributors <[email protected]>
* fix(cli): validate rig ask --wake-timeout before handing it to execFile
`--wake-timeout abc` became NaN and `--wake-timeout 1.0005` became
1000.5ms. execFile rejects both with ERR_OUT_OF_RANGE, so the wake
crashed with a stack trace (and printed no JSON under --json). Reject a
non-finite value with a clear message before any daemon or runtime
call, and round the timeout to whole milliseconds.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(cli): bound rig ask --wake-timeout to what a Node timer honours
Review follow-up: `--wake-timeout 1e308` passed the finite-seconds check
but overflowed to Infinity ms, which execFile still rejects. And any
value of 2^31 ms or more (about 24.8 days) is run by Node's timer after
1ms, so the wake was killed at once and reported as timed out. Check the
converted milliseconds against 2^31-1 before any daemon or runner call.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
`rig chatroom wait --timeout abc` parsed to NaN. Neither timeout check
ever fired and the poll slept Math.min(3000, NaN), which Node runs as
~1ms, so the command polled the daemon's chat history in a tight loop
forever. Validate --timeout up front and exit 1 with a clear message
before contacting the daemon.
Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
OmpRuntimeAdapter and OmpResumeAdapter extend the Pi adapters. Since #212,
Pi-family launches send the runner command through
TmuxAdapter.sendShellCommand instead of sendText followed by Enter. #35's
CI ran before #212 merged, so its test mock only had sendText and sendKeys.
On main, all five OMP adapter tests fail with "this.tmux.sendShellCommand
is not a function".
OMP shares the Pi runner, so it should inherit Pi's launch transport. This
is a test-only change: the fake tmux now exposes sendShellCommand, and the
three assertions read the launch command from it.
Refs #35
Refs #212
Claude-Session: https://claude.ai/code/session_0111rhBDUYTL2n3Dbr2HDJf1
Co-authored-by: v-openrig-build <[email protected]>
Add `runtime: omp` as a managed runtime that runs Oh My Pi through
OpenRig's RPC runner, separate from `runtime: pi`.
Runner and launch:
- Share the Pi RPC runner with OMP-specific launch arguments, approval
mode (`always-ask` floor, `yolo` under a full-bypass policy), and a
deny-by-default environment allowlist.
- Isolate each seat under `$OPENRIG_HOME/state/omp/<seat>` with a per-seat
HOME so OMP never reads the operator's `~/.omp` or `~/.env`. Resolve the
real `omp` binary first so version-manager shims keep working.
- Cancel headless approval/input requests and keep the seat in
needing-attention until the next agent run; surface rejected prompts and
in-turn model/provider errors once per turn.
Identity and resume:
- Persist the OMP session file as the resume token only once it exists on
disk and matches the seat's own session file; retry identity delivery
until the daemon confirms the token.
- Validate the persisted seat runtime and session name on identity hooks,
and report `tokenPersisted` from the stored state for RPC runtimes.
- Capture and resume OMP session files through claim, handover, and
restore paths.
Recognition and docs:
- Recognize OMP in RigSpec validation, preflight (`omp --version`),
runtime verification, session fingerprinting, and discovery.
- Document OMP state, credentials, approval posture, model errors, and
restore in the RigSpec reference; add OMP UI branding.
health-detectors.ts, snapshot-repository.ts and restore-plan-preview.ts each
kept a private copy of the SQLite-stamp-to-UTC conversion that sqlite-time.ts
now provides. The copies had drifted:
- restore-plan-preview always appended "Z", so an already-zoned ISO
last-verified stamp became "...ZZ", parsed to NaN, and skipped the
freshness check: a 2h-old token showed as present instead of stale.
- health-detectors treated any value containing "T" as zoned, so a
zone-less ISO value would have been read as host-local time.
All writers currently store datetime('now') stamps, so neither drift was
reachable in production; this removes the traps rather than fixing a live
bug. Adds a restore-plan-preview case for the ISO stamp (fails before this
change) and a helper case for the T-separated zone-less form.
Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(security): enforce mode 0600 and exclusive creation on sendText temporary files
* fix(security): prevent DNS rebinding terminal hijack and add OPENRIG_ALLOWED_ORIGINS support in terminal WebSocket
* fix(security): reject backslash path traversal and Windows drive absolute paths in bundle unpacking
* fix(security): tighten loopback IP and URL origin matching in terminal-ws
* fix(security): only unlink temporary file in sendText if created by this call
* perf(daemon): capture every seat's pane in a few tmux calls per structural sweep (#308)
The structural activity sweep forked one `tmux capture-pane` per running seat
every tick; each fork blocks the event loop ~12-20 ms at ~1 GB RSS, and a
73-seat host spent about a third of its main thread in spawn (#308).
TmuxAdapter.capturePanesContent: one `list-sessions`, then the live sessions
in chained calls of at most 24 (`display-message` marker unique to the call,
then `capture-pane`, exact `=<name>:` targets), each pane's text identical to
capturePaneContent's. A missing session maps to null without a fork. tmux
stops a chain at its first failing command, so a failing chunk is split in
half and retried: an output over the exec buffer fits, and a session gone
since the listing is isolated and left to the per-seat read. No listing ->
null -> the sweep reads per seat as before.
The sweep uses it once per tick: 1 + ceil(live/24) calls instead of one per
seat (72 live seats: 4 instead of 72). pollSeat takes the prefetched text and
otherwise captures per seat; MF1 (a null capture invalidates) and MF2
(single-flight) are unchanged. Exact targets also resolve a session name with
a ".", which `-t <name>` reads as window.pane.
Tests: the adapter against a fake tmux with real chain semantics in both the
shell-string and argv modes (listing + one call, missing -> null, chunks of
24, a vanished session isolated, an over-buffer chunk split, no listing ->
null, marker nonce), and the sweep (one batch, MF1, per-seat fallback).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(daemon): doc comments on the batched capture helpers (review)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(daemon): a batched capture keeps its own time; a slow later chunk can't renew it (review)
The sweep waited for every batch and then stamped each pane's text as observed
"now", so a slow later chunk renewed older captures as fresh: an idle pane's
5.2 s-old active text was projected as running where main lets it expire.
capturePanesContent returns { text, capturedAt } per target, capturedAt being
when that target's own chunk was read (missing sessions: the listing time);
it takes the service's clock. pollSeat stores that time as observedAt, so the
freshness window ages each capture from when it was actually read.
Tests: a slow second chunk gets its own later capturedAt while the first
chunk keeps its earlier one (both exec modes); after a 5.2 s sweep an early
capture is past the 5 s window and is not served, while a fresh one is.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(queue): a human-decision-resolved notice on a closed row opens no delivery episode
Rows a human closed by a direct Slack reply read deliveryOutcome=never-posted although their ALERT was posted. The close
writes a human-decision-resolved owner notification, which the Slack outbound never posts (listHumanAlerts lists active
rows only). The ledger took it as the current episode, found no receipt for its key, and reported never-posted after the
post window. On a row that is no longer active, the episode now falls back to the latest outbound notice
(human-required / human-update). Active rows are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(queue): judge a resolved notice by its own transition, and resolve the closing human (review)
Maintainer review: the fallback used the row's CURRENT state, so a resolved
NOTICE that was really posted (or transport-failed) while the row was active
lost its outcome once an agent later closed the row. The fallback now applies
only when the resolved notice's own transition closed the row, i.e. a human's
direct reply closing it; a notice written while the row stayed active keeps
its own outcome whatever happens to the row later.
CodeRabbit: after the fallback the recipient came from the row's current
destination, so an agent's row that had been parked on the human (its
destination the agent, blocked_on cleared by the close) read inactive. The
recipient is now the human who closed it (the resolved notice's actor).
Tests: "notice posted while active, then the agent closes the row" for posted
and transport-failed receipts; an agent's row parked on the human, ALERT
posted, closed by the human's reply -> posted.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(cli): replace proof artifacts without writing through links
`rig proof add --replace` opened the existing artifact name with the "w"
flag. That follows a symlink and truncates a shared inode, so replacing
an artifact name that was a symlink or hard link overwrote the other
path's bytes, including files outside the slice and proof media.
--replace now writes a uniquely named staging file next to the artifact,
created exclusively, and renames it over the name. The old link or inode
is never opened for writing, so other paths keep their bytes. A failed
write or rename leaves the target as it was and removes only the staging
file this call created; the original error is reported. A self-sourced
replace still works because the source is read before the write. The
non-replace path keeps its exclusive-create refusal.
The replaced artifact is a new inode with default permissions. There is
no fsync, so this is a directory-entry swap, not a crash-durability
guarantee.
* fix(cli): keep artifact permissions and the first failure on proof replace
An explicit --replace of an existing regular artifact now carries its
permission bits onto the staging file before the swap, so a replacement
never broadens access (a 0600 artifact stays 0600; a read-only artifact
is replaced and stays read-only). A symlinked or new name still gets
default permissions.
The write and close failures are collected separately: the first one is
reported and a later close failure is a warning. A close failure on its
own still refuses the rename, and the target keeps its bytes either way.
* fix(cli): create the proof replace staging file no wider than the artifact
The exclusive staging open now passes the replaced artifact's permission
bits (or the default 0666 for a symlinked or new name) as its creation
mode, so the staging file is never created wider than the artifact it
replaces. The fchmod still sets the exact bits regardless of umask.
---------
Co-authored-by: OpenRig contributors <[email protected]>
* fix(daemon): recognise the Codex 0.157 empty-composer placeholder as idle
Codex 0.157 renders a fixed placeholder in the empty composer
(`› Ask Codex to do anything`, codex-rs/tui/src/chatwidget.rs `PLACEHOLDER`)
and its footer (`← for agents · ? for shortcuts`) no longer carries the
`· Context [` status bar. Once the activity hook ages out
(AGENT_ACTIVITY_FRESHNESS_MS), send readiness falls back to the pane probe,
which classifies an idle Codex 0.157 seat as `unknown`; with --wait-for-idle
every send to such a seat is refused as `target_activity_unknown`.
The placeholder and footer look the same idle and mid-turn. Mid-turn the
status row (`Working … esc to interrupt`) normally sits above the composer,
so the pattern only counts through MID_WORK_PATTERNS. Codex hides that row
while it streams assistant output, so classifySendReadiness also refuses to
let a placeholder-only idle verdict override a display-fresh (<5min)
running/needs_input runtime hook: a running turn stays running until its hook
ages out. An `unknown` hook (e.g. SessionStart) does not block.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(daemon): a Codex placeholder composer with a Working row anywhere above it is active (0.158)
Codex 0.158 keeps the 0.157 placeholder (`› Ask Codex to do anything`); its
footer is a status line plus `? for shortcuts`, so the placeholder pattern
covers it. But its status row (`Working … esc to interrupt`) can sit more than
8 non-blank lines above the composer when queued or incoming message blocks
come in between. The mid-work check only looks at the last 8 lines, and an idle
prompt line then falls through to idle_prompt, so a busy seat read idle, and
with its hook aged out a --wait-for-idle send landed mid-turn.
Under the placeholder, a mid-work line anywhere in the capture now gives
agent_active / mid_work_pattern with that line as evidence. A bare prompt keeps
today's stale-scrollback behaviour. Tests: 0.158 idle and busy pane tails
(redacted from live seats), the far Working row (classifier and a
--wait-for-idle send with an aged-out hook), and a bare prompt with stale
Working text beyond the window still idle.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(daemon): only Codex's turn-status row counts as busy beyond the 8-line window (review)
The long-range scan under the placeholder matched the generic Working pattern
anywhere in the capture, so completed output such as "• Working directory:
/tmp/project" or "• Working tree is clean." more than eight lines up made an
idle composer read busy, and a --wait-for-idle send timed out. At that range
only the turn-status row now counts: a bullet, a header (Working, or the
reasoning summary Codex shows in its place), then "(<elapsed> • esc to
interrupt)". The 8-line generic check is unchanged.
Tests: both completed-prose cases read idle; a far status row under a
reasoning-summary header reads active; a send with "Working tree is clean."
far above the placeholder is delivered.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Oskar Stępniak <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
CodeRabbit on #298: defaultReadLocalImage resolved the path twice, statSync
for the checks and readFileSync for the bytes, so the file checked need not be
the file sent. Now the path is opened once (O_RDONLY | O_NONBLOCK, so a FIFO
can't block the open; it is refused as not a regular file), fstat'ed, and read
from the same descriptor, bounded by the size just checked; the descriptor is
always closed.
Tests: the reader opens the path once and never calls statSync/readFileSync on
it; a FIFO with an attachment extension returns "not a regular file" without
waiting for a writer.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* feat(slack): structured questions with clickable options on human decisions (#193)
- `rig queue create --human-questions-file`: 1-4 questions, 2-4 options each
(one may be recommended), validated at create; stored by migration 091.
- Slack renders each question as a button row with a complete text fallback;
a typed thread reply still answers ("Other").
- A block_actions click records that answer on the item; once every question
is answered the answers are final, one reply row lands on the seat and the
decision resolves like a typed reply. Failed hand-backs are dead-lettered
and retried; each click is confirmed in the thread.
- App manifest turns on interactivity (Socket Mode, no request URL); setup
doc and messaging-the-human skill updated.
* fix(slack): own-key answer lookup; require a human destination for questions(#193)
* fix(slack): redact secrets in answer acknowledgements (#193)
The file title sent with files.completeUploadExternal was the queue summary
(or the file name) as is, while the message text carrying the same summary
already went through redactSecrets. A secret in a row summary was therefore
hidden in the text but shown as the uploaded file's title. The title now
goes through the same redaction. Slack does not parse file titles as mrkdwn,
so the text's control-syntax escaping is not applied to it.
Fixes#300
Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN
Co-authored-by: v-openrig-build <[email protected]>
The daemon no longer serves the web UI's pages, assets and SPA routes, or
registers the web-terminal WebSocket, unless `ui.enabled` is true. Turn them
on with `rig config set ui.enabled true`, then stop and start the daemon.
While the UI is off, a page request answers 404 with those steps, and
`rig ui open` asks the running daemon and prints the same steps instead of
opening the page.
The /api routes, /healthz and the HTTP terminal routes (open, views, preview,
status) answer as before. The guard that ran in front of the HTTP terminal
routes through the WebSocket route still runs when the WebSocket is absent.
Co-authored-by: OpenRig contributors <[email protected]>
An update row's local evidenceRef screenshot delivered as text only: the daemon
logged "ATTACHMENT upload-url FAILED ...: invalid_arguments (text delivered;
attachment missing)". files.getUploadURLExternal was sent as a JSON POST.
Slack's reference lists JSON for it, but the method reads form fields only and
answers a JSON body with invalid_arguments ("missing required field: length /
filename").
- callWebApi gets a "form-post" shape: url-encoded fields, objects/arrays as
JSON strings, the way Slack's own Web API client sends every call. Both
files.getUploadURLExternal and files.completeUploadExternal use it; the
JSON-POST family (auth.test, apps.connections.open, chat.postMessage) is
unchanged.
- Local attachments (LOCAL_ATTACHMENT_EXT): the image types plus .mp4, .webm,
.mov and .pdf, at most 50 MiB (stat before read). LOCAL_IMAGE_EXT still gates
https Block Kit image blocks (#47), so a video URL never becomes one.
- The byte upload's default timeout grows with the size (15 s + 1 s / 512 KiB).
- An attachment that can't be sent (over the cap, missing, not a regular file)
logs "ATTACHMENT skipped ... (text delivered; attachment missing)" instead of
being silently dropped; a non-attachment ref (e.g. PROOF.md) stays silent.
Tests: the slack-images fake now answers a JSON getUploadURLExternal with
invalid_arguments, as live; form shapes for both upload methods; video through
the three legs; the reader's allowlist, cap boundary and skip reasons.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The README opening now says what OpenRig is in one sentence (open-source
software for building and running your own network of agents) and closes
with the line about the AI civilization experiments. The npm package
description and the Context7 description use the same short summary:
"Build your own network of agents from Claude Code, Codex and Pi:
long-running teams with roles, shared context and owned work."
Co-authored-by: v-openrig-build <[email protected]>
* feat(slack): let an update post into an earlier item's thread (--reply-to) (#96)
`rig queue create --human-intent update --reply-to <qitemId>` posts the
update under the earlier item's Slack thread instead of a new top-level
post. Decisions keep their own threads.
- Refused with named errors: without --human-intent update
(reply_to_requires_update), unknown item (reply_to_not_found), or while
the earlier item still waits on the human (reply_to_open_decision).
- The thread choice is recorded once, before the first post, so retries
and --verify agree. Only daemon-written records are trusted.
- A missing, closed, other-channel or other-human root falls back to a
new post; --verify reports threaded: false with the reason.
- If an item is parked on the human again after an update shared its root,
the new decision gets a fresh root, and a reply in the older root no
longer answers it.
* fix(slack): address #155 review — seat check, thread_ts ordering, multi-thread tests
- --reply-to falls back with root-other-seat when the root belongs to a
different seat, so a human reply can't reach the wrong agent. The
messaging-the-human skill now says to send the update from the seat
that owns the thread.
- The newest root of a conversation is Slack's latest thread_ts, not
opened_at, which a rebuild resets. This applies to all outbound thread
reuse, not only --reply-to.
- Pin that a reply in an older thread of a conversation no longer
answers its current decision, without --reply-to.
- Pin that the resolved-decision notice never takes the root away from a
later --reply-to update (Slack-answered decision and resolved park).
* fix(slack): post a --reply-to update top-level instead of refusing it (#155 review)
An update whose --reply-to item still waits on the human is no longer
refused at create time (reply_to_open_decision is gone). Delivery already
falls back: it posts the update as a new top-level message and records
why on the row, so --verify reports threaded: false with
reference-has-live-gate. A missing thread already fell back the same way
(root-missing).
- Still refused: --reply-to without --human-intent update
(reply_to_requires_update), and an id naming no qitem on this host
(reply_to_not_found).
- Tests: create accepts a reference with an open decision or a row parked
on the human; delivery posts both top-level with the fallback reason.
- CLI help and the messaging-the-human skill describe the fallback;
skill edge digests regenerated.
The 1 Hz seat-activity sweep ran one `tmux display-message` per seat, and the
identity sweep a getPanePid + getPaneCommand per seat (plus getPanePid again per
native-process sample). At 87 seats that was ~85 tmux spawns/s from the
activity sweep alone and most of the daemon's event-loop time.
- TmuxAdapter.readAllSessionWindowActivity(): one `list-windows -a`, each
session's CURRENT window activity: the value `display-message -t <session>`
reads.
- TmuxAdapter.readAllPaneProcesses(): one `list-panes -a`, pane pid + current
command.
Both use the adapter's argv seam and TMUX_FIELD_SEPARATOR, with the free-text
field (session name, command) last so a separator inside it stays intact.
The sweeps read once and look each seat up, falling back to the per-seat read
on a miss or a failed batch. Direct pollSeat calls still read per target.
Cadence, debounce, arbitration and #169's generation checks are unchanged; each
native-process sample takes its own list-panes, so samples stay independent.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Onboarding piece 01 told only half of the doghouse story: the build that
over-grows (now a light that needs power, not a lock) and then asked "How
big is the dog?", which belongs to the other half. It now tells both: the
moon base, stopped by "does the thing actually need this?", and the tidy
doghouse whose door the dog can't fit through, stopped by "how big is the
dog?". A short paragraph adds that shared understanding of the goal, not
tighter instructions, is what prevents both.
Piece 02's ending becomes "When you need more": the capability map is read
when needed rather than by every seat, a world pack is for planning and
routing work, and the world template is pointed to for people setting one up.
The delegating-work skill gains "Hand over intent, not instructions".
Co-authored-by: v-openrig-build <[email protected]>
A mutating `rig seat handover` (and `rig handover`, which shares it) waits up to
120 s for the daemon (#260). When the client reaches that bound the daemon may
still be working, so the handover's outcome is unknown, but the CLI surfaced only
a raw transport timeout (reported in korallis/agent-stack#32).
The CLI now prints "handover outcome unknown" in text and JSON, exits 1, and
makes no second request. It tells the user to inspect the seat before any new
handover, and that a result shown there may belong to an earlier attempt.
A completed or refused handover, a connection failure, and dry-run behave as
before.
Co-authored-by: OpenRig contributors <[email protected]>
* docs: add contributor maps and a developing-openrig skill
Add ARCHITECTURE.md (packages, request path, counts with the commands that
produce them, where to add a command, route, migration, adapter, skill,
context pack or scenario), docs/as-built/arteries.md (high-impact areas,
their dependents and past regressions) and docs/as-built/test-layers.md
(what to run before a pull request and what each layer proves).
Add a repo-local developing-openrig skill for Claude Code (.claude/skills)
and Codex (.agents/skills) that routes to these maps, with narrow .gitignore
exceptions so only that skill is tracked. Point CONTRIBUTING.md at the maps
and correct its skill-mirror instructions. Replace the private-host paragraph
in the shipped openrig-skills router with a pointer to the repository skill.
* docs: correct six factual points from review
Newest as-built markers; skills may have one or several copies; the host
scenario runner drops rather than refuses TMUX and daemon variables;
scenario-10 declares its own normaliser; the managed Claude launch applies
only with an explicit permission mode; Dockerfile.scenarios is layered by
run-pr-scenarios.sh. Also tighten several wordings the review flagged.
---------
Co-authored-by: v-openrig-build <[email protected]>
* fix(security): origin guard on /api/*, target URL validation, pairing hygiene, and UTC test fix
- API Origin protection: add apiOriginProtection middleware rejecting unauthorized browser Origin headers on /api/* routes to prevent drive-by web pages posting to the local daemon.
- Notification target URL validation: refuse non-HTTP(S) schemes and embedded URL credentials with clear startup errors and URL credential redaction, set redirect: 'error' on webhook delivery, and preserve self-hosted notifier support on localhost/LAN.
- Pairing hygiene: synchronously reserve capacity before awaited calls to prevent race conditions exceeding MAX_PENDING_PAIRS, prune requests only after a 1-hour grace window past PAIR_TTL_MS so waiting clients receive status 'expired', and sanitize requester strings.
- Timezone test fix: use parseSqliteUtcMs in effective-model-occupant-identity.test.ts to eliminate non-UTC test failure.
* fix(notifications): log warning and disable on invalid target URL, drop redirect error
* fix(notifications): support basic auth credentials in webhook and ntfy URLs
* fix(notifications): safely decode URI components in target URLs falling back on URIError
* fix(daemon): deliver built-in startup files from the running install (#261)
Built-in startup files (CULTURE-default.md, openrig-start.md and the two
onboarding files) are stored per seat as absolute paths inside the install
that created the seat. After an upgrade removes that install (mise and other
versioned managers), every fresh launch rolled back with ENOENT, and restore
blocked on the missing required file. While the old install remained, seats
kept receiving the old version's shipped guidance.
Fresh launch (SeatLifecycleService.readStartupContext) and restore
(pre-validation and replay) now re-anchor a stored file to the running
daemon's assets when it is a recognized built-in: one of the four logical
names, stored at exactly <ownerRoot>/<known relative path>, with ownerRoot a
daemon/assets directory. Custom rig and agent files, including a same-named
CULTURE-default.md, are delivered exactly as stored. Required/appliesOn/
delivery metadata is unchanged, a missing current built-in still fails
honestly, and a same-native resume still replays nothing.
Refs #261
* fix(daemon): project shipped-spec resources from the running install (#261)
Kernel and library agents ship inside the install, so their stored projection
entries (agent guidance, shared runtime settings/MCP fragments) also point
into the install that created the rig. After that install is removed, a fresh
launch still rolled back in projection, before startup-file delivery.
Fresh launch and restore (pre-validation drift checks and replay) now
re-anchor a stored projection entry to the running install's daemon/specs when
both its sourcePath and its resource path lie under the same OpenRig install's
daemon/specs (packaged @openrig/cli/daemon/specs or a packages/daemon/specs
checkout). A resource stored elsewhere, such as the openrig-core plugin under
~/.openrig/plugins, user specs, or a folder merely named daemon/specs, is
projected exactly as stored. Identifiers, category, target, merge behavior and
the existing skill exclusion are unchanged; a resource missing from the
running version keeps the existing failure or warning.
Refs #261
* fix(daemon): own-key built-in lookup; restore-check judges the running paths (#261)
The built-in name lookup used a plain object, so a custom startup file named
constructor, toString or __proto__ matched an inherited key and fresh launch
threw on a valid seat (review finding on be599a64). Use a Map.
RestoreCheckService.checkStartupContext checked and reported the stored
paths directly, so a seat whose old install was removed showed missing
required inputs that replay no longer uses, and named stale paths in its
evidence and remediation. It now applies the same resolvers to the inspected
context and uses the resolved paths in both the predicates and the reported
text. The red/yellow rule, genuinely missing current or custom files, and
missing/malformed/probe-error handling are unchanged. The route's context
reader passes ownerRoot and sourcePath through so the resolvers can apply.
Refs #261
* fix(daemon): shipped-spec startup files follow the running install (#261)
Kernel and library seats also store their rig culture and agent startup files
(culture/CULTURE.md, guidance/role.md, startup/context.md) as absolute paths
inside the install's daemon/specs. With the projection fixed, a kernel created
by a removed install still rolled back on the culture file, and its post-launch
role/context would have followed.
The startup-file resolver now also re-anchors a stored startup file whose
ownerRoot and absolutePath both lie under the same recognized install's
daemon/specs, using the same mapping as projection entries. It applies through
fresh launch (pre- and post-launch delivery), restore replay and
pre-validation, and restore-check. Custom, external and plugin paths, and all
required/applicability/delivery metadata, are unchanged; a file missing from
the running version still fails or warns honestly.
Refs #261
* fix(daemon): re-anchor dev-checkout paths only when missing (#261)
A daemon running from one install remapped every recognized shipped path,
including ones stored from a developer checkout (packages/daemon/...). Those
files can be newer than, or absent from, the running install, so a present
file was remapped to a missing running path and the launch rolled back
(review finding, confirmed by both reviewers).
Packaged installs (@openrig/cli/daemon/...) still re-anchor even while the
stored file exists, so seats never keep an old version's shipped content.
Dev-checkout files re-anchor only when the stored file is missing. Recognition
is limited to those two layouts for built-in assets too. Each consumer passes
its own existence check (restore fsOps, restore-check deps; fresh launch uses
the filesystem).
Refs #261
* fix(daemon): leave legacy persisted startup shapes as stored (#261)
Snapshots and contexts persisted by older versions can lack ownerRoot on
startup files or sourcePath on projection entries. The #261 resolvers called
path.resolve on those fields, which threw, so restore reported restore_error
instead of its real outcome; restore pre-validation walks projection entries
even for an exact resume (CI: restore-honesty-d1-d6 D6a, 4 failures).
Entries without a string root or path are now returned exactly as stored,
which is their pre-#261 behavior.
Refs #261
---------
Co-authored-by: v-openrig-build <[email protected]>
* fix(cli): use current home daemon endpoint for local clients
* fix(cli): retain recorded endpoint after daemon exit
---------
(cherry picked from commit cdf5fff724)
Co-authored-by: dev-driver <dev-driver@openrig-build>
A managed Claude launch with an explicit permission mode first runs
`claude --help` to read the supported modes. The query was killed after one
second, so a valid but slow help refused the launch with "capability query
failed". The daemon now allows five seconds; a hung query still ends at that
bound with the same refusal. `rig seat set-permissions` waits up to 10 seconds
and a mutating `rig seat handover` gets the existing 120-second launch window,
so the daemon's answer arrives before the client deadline.
Port of #271 from release/0.6.4 (squash bfa66821). Original author:
dev50-driver; one-line S03 test finisher: dev60-driver.
Refs #260
Co-authored-by: OpenRig contributors <[email protected]>
Bundle create read every vendored agent-package file, and the declared
culture, docs and startup files, as UTF-8 text and wrote it back as UTF-8.
Any byte sequence that is not valid UTF-8 became U+FFFD, and the integrity
manifest was hashed from the changed copy, so inspect and install verified
the corrupted bytes as intact.
Copy those files as bytes: the pod assembler reads them with readFileBuffer
and writes the same bytes, and the install-side skills and workflow-spec
routers copy with fs.copyFileSync. Rewriting rig.yaml and agent.yaml import
refs stays a text operation.
Port of #257 from release/0.6.4 (squash 2174b11e). The async test change in
that squash is already on main via #268 and is not repeated here.
Fixes#245
Co-authored-by: OpenRig contributors <[email protected]>
Stopping the server's last session ends tmux's server, so launchFresh's
later probes saw transport_unavailable and refused with tmux_probe_failed
after the occupant was already stopped. After its own successful stop,
launchFresh now restores an empty server with the existing startServer()
(a no-op while the server is up; no session is invented), so the
classified probes get a positive answer. Transport, permission,
collision, claimed-seat and wrong-pane refusals are unchanged.
Port of #267 from release/0.6.4 (squash fa770114), without its RC-only
CHANGELOG notes.
Refs #265
Co-authored-by: OpenRig contributors <[email protected]>
The npm page for @openrig/cli shows "No README data found" because the
published package root has no README.md. The CLI build already copies the
root LICENSE into the package; it now copies the root README.md the same
way, and the generated copy is ignored like LICENSE. The packaging test
now also checks that npm pack carries README.md byte-for-byte.
Co-authored-by: v-openrig-build <[email protected]>
* fix(daemon): prove managed Claude identity during restore
* fix(daemon): sample Claude lineage only for shell labels
* test(daemon): distinguish bare shells from unusable Claude panes
* fix(identity): retain unavailable final Claude command observation
---------
Co-authored-by: dev-driver <dev-driver@openrig-build>
The three anti-sync discriminators in list-processes-async.test.ts raced a
0ms timer against the sampler's result. Timer order is not a valid guarantee
of async execution: in a local probe, a 70ms synchronous stall after spawning
a single-PID `ps eww` let the result settle before the 0ms timer in most
trials. The CI failure of this test is consistent with that race; the CI
timing itself was not measured.
Assert instead that the returned promise is still pending after draining 20
microtask ticks: an async execFile cannot settle without an event-loop turn,
while a synchronous ps inside the call settles within a few ticks. The real
rows and HOME assertions are unchanged.
Refs #245
Co-authored-by: OpenRig contributors <[email protected]>
client.test.ts expected only MAJOR.MINOR.PATCH, so the 0.6.4-rc.1 candidate failed
Tests run 36798254083. Allow an optional pre-release suffix and keep the anchors and the
optional (commit[, dirty]) stamp. Test-only.
Co-authored-by: OpenRig contributors <[email protected]>
* test: bind scenario seeds and exercise the library baton in CI
* test: run named scenarios through a remote Docker executor
* test: bound and clean up the testbed image-load check
---------
Co-authored-by: dev-driver <dev-driver@openrig-build>
Co-authored-by: dev-qa <dev-qa@openrig-build>
2026-09-30 17:55:20 -07:00
2056 changed files with 224241 additions and 19564 deletions
description:"Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| What OpenRig can already do, before you decide something is missing | `rig context get onboarding-width/public-what-you-can-do.md`; in this checkout, `packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md` (the installed copy can differ) | The capability map; check it before proposing a new command or calling a gap a defect |
| Whether a `rig` command already exists | `rig --help`: the full command list, before you decide a command is missing | The running binary; read the whole list, not only the command you expected |
| Whether a problem is already known | The "Known problems to compare against" section of [`docs/reference/help.md`](../../../docs/reference/help.md) | Lists known issues by symptom; check an issue's current status before relying on it |
| Whether a feature is already planned | [`ROADMAP.md`](../../../ROADMAP.md) | Check it before proposing a feature |
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Each module names the commit it was last checked against in `last-verified-against-source`; compare that marker with `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| Which shipped skill covers a task | [`skills/_canonical/core/openrig-skills/SKILL.md`](../../../skills/_canonical/core/openrig-skills/SKILL.md) | The index of the skills that ship with OpenRig |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- OpenRig runs on agents working in context with tools. Before adding code for a behaviour, ask whether an agent with
the right instructions and today's commands could do it. A skill or a page of guidance is often the better change.
- Where to look: the capability map above, and the `agent-operated-workflows` skill, which ships in the
description:"Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| What OpenRig can already do, before you decide something is missing | `rig context get onboarding-width/public-what-you-can-do.md`; in this checkout, `packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md` (the installed copy can differ) | The capability map; check it before proposing a new command or calling a gap a defect |
| Whether a `rig` command already exists | `rig --help`: the full command list, before you decide a command is missing | The running binary; read the whole list, not only the command you expected |
| Whether a problem is already known | The "Known problems to compare against" section of [`docs/reference/help.md`](../../../docs/reference/help.md) | Lists known issues by symptom; check an issue's current status before relying on it |
| Whether a feature is already planned | [`ROADMAP.md`](../../../ROADMAP.md) | Check it before proposing a feature |
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Each module names the commit it was last checked against in `last-verified-against-source`; compare that marker with `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| Which shipped skill covers a task | [`skills/_canonical/core/openrig-skills/SKILL.md`](../../../skills/_canonical/core/openrig-skills/SKILL.md) | The index of the skills that ship with OpenRig |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- OpenRig runs on agents working in context with tools. Before adding code for a behaviour, ask whether an agent with
the right instructions and today's commands could do it. A skill or a page of guidance is often the better change.
- Where to look: the capability map above, and the `agent-operated-workflows` skill, which ships in the
| `packages/cli` | `@openrig/cli` | The `rig` command, the MCP server, daemon start and stop | The only published package |
| `packages/tui` | `@openrig/tui` (private) | The terminal UI | Copied into `@openrig/cli`; opened by `rig tui`, by bare `rig` in a terminal, or by the `openrig-tui` bin |
| `packages/ui` | `@openrig/ui` (private) | The React web UI, in **maintenance mode**: it still ships and runs, but gets no new feature work. The CLI and TUI are the supported surfaces | Built files copied into `@openrig/cli` and served by the daemon |
| `packages/test-system` | none (not an npm workspace) | The stub-agent scenario library, the CI scenario harness, live-model eval cases | Not shipped |
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
OpenRig turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
OpenRig is open-source software for building and running your own network of agents. It turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
**Guide:** [Getting started](docs/reference/getting-started.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
It's the open-source system behind my AI civilization experiments.
**Guide:** [Getting started](docs/reference/quickstart.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
## See it running

**Start here:** [the guided first-use path](docs/reference/getting-started.md): install, launch a two-agent team in your repository, and get one reviewed change.
**Start here:** [the guided first-use path](docs/reference/quickstart.md): install, talk to the kernel operator, then choose a team for your first useful change.
Not setting this up today? Get the next walkthrough and occasional OpenRig updates → https://openrig.dev/follow
## Install and first run
Requires Node.js 22 or 24 and tmux, on macOS or Linux. On a Mac with Apple silicon, use Node.js 22 ([compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation)). Native Windows is not supported yet, and WSL2 has not been tested. Launching a rig writes provider hooks and workspace trust settings. Before running the commands below, read [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) and back up the relevant files.
**Let your coding agent set it up.** First check that `node --version` shows 22 or 24: the command below needs
Node.js and the `npx` that comes with it. Then, in your project folder, add OpenRig's `rigs` skill to the agent you
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
you what it will run and change before it does, install OpenRig, open the OpenRig view and pass your goal to
OpenRig's operator.
You need Node.js 22 or 24 (22 on Apple silicon), tmux, macOS or Linux (WSL2 on Windows), and Claude Code or Codex
signed in. One provider is enough to start.
Already have OpenRig? Use the tag that matches `rig --version`, or upgrade first.
Prefer to type the commands yourself? [Install it yourself ↓](#install-it-yourself)
Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Code or Codex account: one is enough, since the kernel operator adapts the shipped `starter` team to whichever you have. On Linux, the distribution's own Node.js can be older (Ubuntu 24.04's is 18); install a supported version with [nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac with Apple silicon, use Node.js 22 ([compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where you aren't an administrator, or where Homebrew belongs to another account (its `brew install` is refused), install Node.js in your own home folder with [nvm](https://github.com/nvm-sh/nvm): run `touch ~/.zshrc` (a new Mac account has none, and nvm's installer only adds itself to a profile file that already exists), then nvm's install command, for nvm v0.40.8 `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash` ([nvm's page](https://github.com/nvm-sh/nvm#install--update-script) has the newest), open a new terminal, and run `nvm install 22`. nvm itself needs no administrator password, and `npm install -g` then installs into your nvm folder. If nvm's installer says you need Apple's command line tools, run `xcode-select --install`, then its install command again. On Windows, use WSL2, the Windows route OpenRig supports; native Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; see [one user's working setup](docs/reference/getting-started.md#wsl2-a-reported-working-setup).
> **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
> Herdr unless you pass `--no-herdr`. Plain `rig setup`, which is what the one-command install runs, checks both
> 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`.
> - 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`.
> - **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.
> On Codex that's full access with no approval prompts (`-s danger-full-access -a never`). On Claude Code, its file
> tools, web fetches and operational commands, including reads under your home folder, run without asking. A
> permission policy, per-agent choice or Codex profile changes that. What your team can do depends on the team you
> choose and your settings; `workshop`, for example, ships with broad access.
> - **Backups:** OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first.
>
> [Everything OpenRig writes, and why →](https://github.com/mvschwarz/openrig#what-openrig-changes-on-your-machine)
### Install it yourself
This runs OpenRig's install script from the `v0.7.0` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/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.
**Or step by step:**
```bash
npm install -g @openrig/cli
@@ -27,46 +86,103 @@ rig setup --dry-run
To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs on Node.js, so install Node.js 22 as well. Bun may block this package's postinstall script, in which case the Node.js and SQLite check described under [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) does not run at install time.
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup, but applying `rig setup` checks both harnesses and cmux. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
npm 11 and later can skip that postinstall script and print `npm warn install-scripts` naming `@openrig/cli`. That's expected: the CLI still works, and only the Node.js and SQLite check was skipped. To run it yourself, use `node "$(npm root -g)/@openrig/cli/scripts/check-abi.mjs"`; `rig doctor` checks the Node.js version only.
Before launching, your agent asks once: **“Allow your agents to run OpenRig commands without repeated permission prompts?” Yes — recommended / No — keep prompts.** This covers every `rig` command, including starting/stopping agents and configuration, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. Pass the choice with `--providers claude` or `--providers codex` so the other reads not selected instead of failed. The one-command install above doesn't know the choice, so it installs and checks both; for one provider, go step by step and run `rig setup --providers <choice>`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
A team seat with no permission policy, per-seat choice or (for Codex) named profile launches with OpenRig's [team default](docs/reference/rig-spec.md#team-launch-defaults): Claude runs ordinary `rig` commands, project reads and common tests without prompting, while lifecycle commands such as `rig up` and `rig down` still ask; Codex also gets the OpenRig workspace and its pod's state directory as writable directories. Before launching, your agent recommends keeping that default, and offers to remember selected OpenRig commands in your native settings only if you want that, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer keeps the team default and leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
If permission prompts are slowing you down, consider the
[workshop bundle](https://openrig.dev/rigs/workshop). It ships with broad access
and [non-interruptive mode](docs/reference/non-interruptive-mode.md): its agents
bypass permission prompts and supported harness warning dialogs. The listing
and before-install view explain that access before you choose it.
Check `tmux -V` and only your selected CLI/login: `claude --version` plus
`claude auth status`, or `codex --version` plus `codex login status`. If needed,
sign in once with `claude auth login` or `codex login`; do not install or log in
to an unused provider.
| Team | Starter | Models |
| --- | --- | --- |
| Two Codex agents | `first-project` | Both `gpt-6-astra` (unchanged) |
| Two Claude agents | `first-project-claude` | Configured native Claude default |
| Claude owner + Codex checker | `first-project-mixed` | Claude default + `gpt-6-astra` |
### Your agent opens OpenRig for you
All three use the same owner/checker roles and task. Show the selected runtime,
configured model and command before launch; confirm the account supports the
model instead of silently falling back. The kernel starts automatically and
selects from available authenticated providers independently of these two project
agents. A missing unused provider is not a setup requirement.
After installing, your agent opens the OpenRig TUI and the operator for you. To get back to them later, ask your
agent for OpenRig or your agents in any words. After checking the selected login, it starts the daemon if it's stopped
and opens the view:
```sh
rig daemon start
rig terminal open saved:kernel --window
```
`--window` opens a new terminal tab or window itself, or a space in your current
Herdr session. Below 120 measured columns,
the **operator** fills the first page; from 120, the dashboard and operator share
it equally. The advisor has a separate tab or tmux window.
An installing agent can run it from its shell on the daemon's
desktop; the person copies nothing. It uses Herdr when installed, otherwise
the same layout in plain tmux, and preserves existing conversations. `rig tui`
is the team dashboard. Installation is finished when you are talking to the
operator; if you choose to talk later, that handoff remains pending.
If setup stops, read the named step: missing tools and selected-provider logins
need their specific setup action. A harness permission rule, sandbox or automatic
permission decision can also block the installing agent's command. The agent
should name the command and reported reason, then help you resolve that specific
step within your chosen permissions. A permission refusal is not an OpenRig
installation result.
The desktop may need an Automation approval; SSH/headless sessions may have no
display. Herdr can show first-run panels, and an 80×24 window can cramp the view.
Follow [the OpenRig view checks and next steps](docs/reference/getting-started.md#what-can-interrupt-installation-and-the-openrig-view).
Herdr is visible only inside a terminal you can see. Changing the shared TUI to
`:terminals` does not open that terminal. The agent checks the visible window when
it has desktop access and says what it could not verify otherwise. Over headless
SSH, it asks you to open a new terminal window or tab and gives the exact connection
and attachment command for the current view. If you choose a manual attachment
after a failure,
ask the installing agent for the exact command using your current operator
session; run that in a new terminal window.
The kernel's operator helps you pick a first team. It asks what you want to build,
recommends one of three, and fits it to the providers you have.
| Team | Talk to | Agents and purpose |
| --- | --- | --- |
| `starter` | `dev-build@starter` | A Claude Code builder and Codex reviewer for one bounded change |
| `workshop` | `orch-lead@workshop` | A lead, builder, QA and reviewer for ongoing work in one repository; a rig bundle the operator installs from its pinned listing |
| `factory` | `orch-lead@factory` | Seven: a lead, advisor, build, QA, design and two independent reviewers for sustained product work |
As shipped, `starter` uses both Claude Code and Codex. With only one of them, ask
the kernel operator to adapt it: it writes a copy of the team for your providers
under the same name. [The naming guide](docs/reference/topology-naming.md) explains
how their pods and roles grow and how to name your own team. `first-project` is
starter's old name and still starts it,
unless you already have a rig named `first-project` or `starter`: then it refuses
and names the `rig up <name> --existing` command that brings that rig back.
Show the selected runtime, configured model and command before launch; confirm the
account supports the model instead of silently falling back. The kernel starts
automatically and selects from available authenticated providers independently of
these teams. A missing unused provider is not a setup requirement.
To launch the starter yourself instead (the manual path, without the operator):
```bash
cd /path/to/your/repository
starter=first-project # or first-project-claude or first-project-mixed
rig specs preview "$starter" --kind rig
rig up "$starter" --cwd . --plan
rig up "$starter" --cwd .
rig tui --shared
rig specs preview starter --kind rig
rig up starter --cwd . --plan
rig up starter --cwd .
```
The kernel provides separate operational support and the shared dashboard. To detach without stopping the dashboard, press Ctrl-b then d; `rig tui --shared` returns to that view. Plain `rig tui` opens an independent view. Closing a viewing terminal does not mean you should relaunch the team.
To see your agents, use `rig terminal open saved:kernel --window`. If that window cannot open, `rig tui --shared` is the named dashboard-only fallback; it does not show the operator conversation. The installing agent should resolve or explain the window failure, not finish by suggesting a command for you to type. Closing a viewing terminal does not mean you should relaunch the team.
Check project-seat readiness with `rig ps --nodes --rig "$starter"` and resolve any authentication, trust or permission prompt before assigning work. Then give the owner one bounded outcome from your repository:
Check project-seat readiness with `rig ps --nodes --rig starter` and resolve any authentication, trust or permission prompt before assigning work. If a seat stopped at such a prompt before its startup context arrived, `rig ps` shows the `rig seat continue <seat>` command that delivers it once the prompt is answered. Then give the builder one bounded outcome from your repository:
```bash
rig send "dev-owner@$starter"'Implement <one useful change>. Track the task in the queue and return its ID. Keep it local, verify the behavior, ask dev-check in this rig to check the exact candidate, and record the result and how I can try it.'
rig queue list --destination "dev-owner@$starter" --limit 1000
rig send dev-build@starter 'Implement <one useful change>. Track the task in the queue and return its ID. Keep it local, verify the behavior, ask dev-review in this rig to check the exact candidate, and record the result and how I can try it.'
rig queue list --destination dev-build@starter --limit 1000
```
Sending a message does not itself create a queue item; the owner records the task. Read the final artifact and the review of its exact candidate, then return to the same owner for the next change. [The guided first-use path](docs/reference/getting-started.md) covers readiness, a useful task, a reviewed result, Herdr/cmux terminals and recovery.
Sending a message does not itself create a queue item; the builder records the task. Read the final artifact and the review of its exact candidate, then return to the same builder for the next change. [The guided first-use path](docs/reference/getting-started.md) covers readiness, a useful task, a reviewed result, Herdr/cmux terminals and recovery.
Not setting this up today? Get the next walkthrough and occasional OpenRig updates → https://openrig.dev/follow
@@ -75,6 +191,7 @@ Not setting this up today? Get the next walkthrough and occasional OpenRig updat
- **Working on OpenRig with a coding agent?** Start with the `developing-openrig` skill in `.agents/skills/` (Claude Code: `.claude/skills/`) and [CONTRIBUTING.md](CONTRIBUTING.md).
- **Releases:** [GitHub Releases](https://github.com/mvschwarz/openrig/releases) and npm `@openrig/cli`
@@ -89,31 +206,42 @@ using a published package, since repository guidance can be ahead of npm.
| When | What changes and why |
| --- | --- |
| **npm installation** | Installs the CLI, bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With`runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules. Agent-guided setup recommends Yes and requires your actual answer before the agent [adds rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
| **npm installation** | Installs the CLI (`rig` and `openrig-tui`), bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. Installs herdr by default on macOS and Linux unless declined with `--no-herdr`; an unavailable herdr install is a warning. On macOS the installing agent offers Ghostty, and `--ghostty` attempts it after acceptance. Existing cmuxsettings are left unchanged. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill, the `refocusing` skill and the person-facing `rigs` skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. When a Codex seat is already recorded and`runtime.codex.hooks_enabled` is enabled (the default), ensures its hook configuration and trust records as described below. With no recorded Codex seat, enabled hooks wait for its first launch; disabled hooks still remove a stale OpenRig-managed hook block, if present. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. In a Git repository, newly created files under `.codex/plugins/shared:openrig-core/` (or the unqualified `.codex/plugins/openrig-core/`) are added to the repository's Git `info/exclude` inside an `# BEGIN OpenRig generated files` block; new `AGENTS.md`, `CLAUDE.md` and `CLAUDE.local.md` files stay visible with a warning. |
| **Bundle install** (`rig bundle install`, or `rig up` with a `.rigbundle` or GitHub link) | Writes the bundle's files into the install target: `--target`, or the current directory for `rig up` and for a GitHub link. `rig bundle install` with a local archive needs `--target`. Routes its declared skills, plugins and context packs into your libraries. A GitHub link's archive is kept under `OPENRIG_HOME/bundle-imports/`, and installs are recorded in `OPENRIG_HOME/bundle-audit.jsonl`. A bundle that carries a project creates it under `workspace.projects_root` and records it, with the rig's association, in the workspace catalog. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules to your settings files; a team seat with no permission policy, per-seat choice or (for Codex) named profile gets the per-launch [team default](docs/reference/rig-spec.md#team-launch-defaults) instead. Agent-guided setup recommends keeping it and requires your actual answer before the agent [adds remembered rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer keeps the team default and preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
The provider files are separate from instance state. Here `~` means the daemon
user's home; changing `OPENRIG_HOME` alone does not isolate provider configuration.
Permission mode controls native execution permissions; work posture is separate
project guidance. Use `rig policy permissions list|show|current|apply` for rig
@@ -151,9 +303,12 @@ separates the desired selection from the last launch arguments; neither proves
native enforcement. See the [permission guide](docs/reference/getting-started.md#per-seat-permission-mode).
Managed hook blocks target OpenRig's entries and retain unrelated hooks, but
trust entries, selected resource keys and Claude's existing status-line command
can be replaced. Some writers recover unreadable settings as empty objects;
this is not a complete preservation or rollback guarantee. Back up relevant
trust entries and selected resource keys can be replaced. Claude's collector,
activity-hook and JSON resource writers leave unreadable or malformed settings
untouched; this is not a product-wide preservation guarantee. Codex's workspace
trust writer can treat an unreadable `config.toml` as empty and write a new trust
configuration. Read-modify-write operations can also race other writers; there
is no complete preservation or rollback guarantee. Back up relevant
files before first use. Daemon/bootstrap writes are automatic and do not each
have an interactive preview; `rig setup --dry-run` does not preview every later
startup effect.
@@ -174,29 +329,22 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
## Starter Rigs
## Teams
Use `first-project`, `first-project-claude`, or `first-project-mixed` for the
same focused first-use path on your selected providers. `product-team` is an optional
larger product-development example:
Three teams grow with you, and the kernel operator helps you pick one:
- **`starter`**: a builder and a reviewer for one bounded change.
- **`workshop`**: a lead, a builder, QA and a reviewer for ongoing work in one repository. It's a rig bundle, installed from its pinned listing.
- **`factory`**: seven agents (a lead, an advisor, build, QA, design and two independent reviewers) for sustained product work. You bring ideas to the advisor, and the lead runs the team.
```bash
rig specs preview product-team --kind rig
rig up product-team
rig specs preview factory --kind rig
rig up factory
```
Use it when you want a larger product squad: two orchestrators, implementation, QA, design, and two independent reviewers.
Specialist teams: `code-review` (two independent reviews), `research` (an analyst and a synthesizer) and `pm` (a product lead, a researcher and a builder for prototypes).
For a smaller starter, use `conveyor`:
```bash
rig specs preview conveyor --kind rig
rig up conveyor
```
`conveyor` is a four-seat starter mixing Claude Code and Codex. It shows a handoff path through intake, planning, build, and review; `first-project` remains the smaller two-seat starting point.
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
Also built in: `secrets-manager`, a HashiCorp Vault instance run by a specialist agent.
Browse the library:
@@ -206,7 +354,7 @@ rig specs ls
## How It Works
OpenRig is a local daemon + CLI + terminal UI + MCP server, built on tmux. The older React web UI remains in maintenance mode with best-effort support.
OpenRig is a local daemon + CLI + terminal UI + MCP server, built on tmux. OpenRig itself is under active development; only the older React web UI is in maintenance mode, with best-effort support.
```
CLI / TUI / MCP
@@ -221,7 +369,7 @@ Hono HTTP daemon
- **CLI**: Commands for both humans and agents to launch teams, inspect state, send messages, track owned work, and manage context.
- **TUI**: Topology explorer, table and graph views, seat details, Specs, Projects, Terminals, Feed, and System. Navigate with the keyboard, mouse, or command bar.
- **MCP**: Tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and a Pi adapter using an RPC runner inside a terminal pane.
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and Pi and Oh My Pi via RPC runners.
## Terminal UI and Workspaces
@@ -234,21 +382,23 @@ The TUI shows the team's coordination state; herdr and cmux show the actual agen
With herdr installed and connected, open the starter's terminals together:
```bash
rig terminal open first-project --provider herdr
rig terminal open starter --provider herdr
```
For cmux, use `--provider cmux`. In the TUI, a rig's detail view has a `term ▸ rig <name>` link that opens every running seat of that rig in the default terminal provider; with herdr that is up to 16 seats per tab, in a workspace named after the rig. The underlying sessions remain accessible through tmux. See the [terminal workspace guide](docs/reference/getting-started.md#share-the-dashboard-and-return-to-it) for setup and returning to an existing view.
For cmux, use `--provider cmux`. In the TUI, a rig's detail view has an`Open terminals ▸ rig <name>` link that opens a terminal view of the running seats of that rig (in a new window or tab, or the current Herdr session), laid out with herdr when installed, otherwise plain tmux; with herdr that is up to 16 seats per tab, in a workspace named after the rig. A seat it can't attach is reported as absent or degraded. The underlying sessions remain accessible through tmux. See the [terminal workspace guide](docs/reference/getting-started.md#share-the-dashboard-and-return-to-it) for setup and returning to an existing view.
## Key Concepts
The first words you meet, in plain language: [What these words mean](docs/reference/quickstart.md#what-these-words-mean).
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
- **Seat**: A stable role and address in a rig, such as `dev-owner@first-project`. The conversation occupying it can change while its identity and authored context remain.
- **Seat**: A stable role and address in a rig, such as `dev-build@starter`. The conversation occupying it can change while its identity and authored context remain.
- **Pod**: A group of related seats with shared guidance and context. Each agent still has its own context window.
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes: resumed, fresh-primed, awaiting-decision (the original conversation can't resume; choose `--fresh`), attention_required, or failed.
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.`rig up`, `rig bundle create`, `inspect` and `install` also take a GitHub folder link, `rig bundle check` checks a folder before you share it, and `rig bundle inspect` shows what a bundle will do before you install it. See [publishing a rig bundle](docs/reference/publishing-a-rig-bundle.md).
- **Culture**: A rig's `CULTURE.md` (its `culture_file`) sets coordination norms for the group, on top of OpenRig's default culture, which every seat receives.
## Agent-Managed Software
@@ -264,7 +414,7 @@ Requires Docker for service-backed rigs.
## Upgrading an existing instance
For an existing installation, follow the [upgrade procedure](skills/_canonical/core/openrig-upgrade/SKILL.md) and the [0.5.14 release notes](docs/releases/v0.5.14.md). Preserve live seats during the upgrade; `rig down` is not an upgrade step. Upgrading to 0.6.0 also requires Node.js 22 or 24: see [Moving off Node 20](#moving-off-node-20) and the [0.6.0 release notes](docs/releases/v0.6.0.md).
For an existing installation, follow the [upgrade procedure](skills/_canonical/core/openrig-upgrade/SKILL.md) and the [0.5.14 release notes](docs/releases/v0.5.14.md); notes for later releases are in [CHANGELOG.md](CHANGELOG.md). Preserve live seats during the upgrade; `rig down` is not an upgrade step. Upgrading to 0.6.0 also requires Node.js 22 or 24: see [Moving off Node 20](#moving-off-node-20) and the [0.6.0 release notes](docs/releases/v0.6.0.md).
### Moving off Node 20
@@ -329,13 +479,16 @@ The helper never removes the legacy telemetry or library. Retirement follows
separate stable runtime, writer, reader, and recovery proof. Daemon, database,
seat, plugin, and release lifecycle actions remain agent-owned.
## Requirements
- Node.js 22 or 24 (the supported versions in this release). Node 20 is no
longer supported. Node 26 and other versions are untested. On a Mac with Apple
silicon, use Node.js 22: see the [compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation).
- tmux
- macOS or Linux. Native Windows is not supported yet, and WSL2 has not been tested
- macOS or Linux. On Windows, use WSL2, the Windows route OpenRig supports; native
Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; one user's
working setup is in the [getting-started guide](docs/reference/getting-started.md#wsl2-a-reported-working-setup)
Optional:
- herdr or cmux for terminal workspaces showing the agents together
@@ -343,9 +496,9 @@ Optional:
## Setup and Troubleshooting
-`rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
-`rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
-`rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
-`rig setup` attempts core machine preparation: it installs a missing Claude Code or Codex and writes tmux defaults. It installs Herdr by default on macOS and Linux; use `--no-herdr` if you decline it. On macOS it also installs a missing tmux with an existing Homebrew; elsewhere install tmux yourself (setup only checks it). On a Mac, the installing agent offers Ghostty once; accepting uses `rig setup --ghostty`, and declining leaves Terminal.app available. Existing cmux settings are preserved. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
-`rig setup --full` attempts a broader operator workstation setup (jq, gh, via Homebrew, so macOS only) on top of core.
-`rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes. It shares setup's local provider login checks. These do not prove provider acceptance or a working agent; also inspect `rig ps --nodes --rig <rig>`.
Both commands support `--json` for agent-driven workflows.
@@ -353,7 +506,7 @@ Before setup or managed launch, review [what OpenRig changes on your machine](#w
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice, and pass it to setup (`rig setup --providers claude`, `codex` or `both`). Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Starting OpenRig and opening its view are the last steps of installing, not extras, so do them without stopping to ask whether to go on: start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
"description":"OpenRig is an open-source multi-agent harness: define a team of Claude Code and Codex agents in YAML, boot it with the rig CLI, and coordinate the agents through tmux sessions, a durable queue and a local daemon.",
"description":"Build your own network of agents from Claude Code, Codex and Pi: persistent teams withroles, shared context and owned work.",
"folders":[],
"excludeFolders":[
"docs/releases",
@@ -18,7 +18,7 @@
],
"excludeFiles":[],
"rules":[
"Install with npm install -g @openrig/cli. OpenRig requires Node.js 22 or 24 and tmux; the first-project starter also requires an authenticated Codex CLI.",
"Install with npm install -g @openrig/cli. OpenRig requires Node.js 22 or 24, tmux, and Claude Code or Codex signed in. The starter team uses both, and the kernel operator adapts it when you have only one.",
"Preview before launching: rig specs preview <spec>, then rig up <spec> --cwd . --plan, then rig up <spec> --cwd .",
"Check seat readiness with rig ps --nodes --rig <rig>, not only daemon health from rig status.",
"Inside a rig, an agent runs rig whoami --json first and treats its output as its identity.",
| [daemon-core.md](architecture/daemon-core.md) | How the daemon boots, `createDaemon` wiring, the SQLite schema/40-migration set, the route-mount surface. |
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract, the Claude/Codex/Terminal adapters (launch/resume/fork in tmux), and the resume-honesty layer (honest resumed-vs-fresh assessment). |
| [coordination-primitive.md](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; where queue closure is enforced. |
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. |
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | Cross-cutting invariants, events and compatibility notes. |
### `ui/` — operator surfaces
### Navigation, checks and reference
| Module | What it covers |
| Document | Read for |
|---|---|
| [shell-and-routing.md](ui/shell-and-routing.md) | The UI shell (rail / Explorer / center workspace / drawer / preview stack), the actual route tree the shipped UI mounts, the shared detail drawer + event consumption. |
| [project-and-for-you.md](ui/project-and-for-you.md) | The operator destination surfaces — the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, the Dashboard landing on the vellum brand system. |
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/skills/plugins/agent-images surfaces, the spec-review + spec-library + live-identity flows, the design-system pointer. |
| [codemap.md](codemap.md) | Question-to-module navigation and source entry points. |
| [arteries.md](arteries.md) | Code paths where changes affect several subsystems. |
| [test-layers.md](test-layers.md) | Test scope, isolation, scenarios and hosted CI. |
| [cli-reference.md](cli-reference.md) | CLI commands and options; confirm changing details in `packages/cli/src/index.ts` and command help. |
| [frontmatter-schema.md](frontmatter-schema.md) | Metadata fields used by these documents. |
| [architecture.md](architecture.md) | Compatibility pointer from the former monolithic architecture page. |
| [DESIGN.md](../DESIGN.md) | Visual and design-system specification. |
### Root docs
### Web UI reference
| Doc | What it covers |
Each page below carries its own verification stamp. This index checks that the pages exist, not
their contents.
| Document | Topic |
|---|---|
| [codemap.md](codemap.md) | Navigation index — module map, structural relationship diagram, fast-lookup-by-use-case, source-root pointer table. Start here when you know *what* you need but not *which module*. |
| [cli-reference.md](cli-reference.md) | The full `rig` CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. Kept as one doc (slice-08 Q4). |
| [frontmatter-schema.md](frontmatter-schema.md) | Which frontmatter convention governs every doc in this tree, plus the one as-built-unique field (`last-verified-against-source`). |
| `../DESIGN.md` | The canonical visual / brand / design-system spec (lives at `docs/` root by design; pointer only — not duplicated here). |
| [ui.md](ui.md) | Compatibility pointer to the UI modules. |
| [shell-and-routing.md](ui/shell-and-routing.md) | Web UI shell and routing. |
| [topology.md](ui/topology.md) | Web topology views. |
| [project-and-for-you.md](ui/project-and-for-you.md) | Project and attention surfaces. |
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | Library/spec views and design-system pointers. |
## Source-grounding contract
For the terminal UI, start at `packages/tui/src/main.ts`, its HTTP client
`packages/tui/src/daemon-client.ts`, and the TUI sections of [test-layers.md](test-layers.md).
Every module declares `last-verified-against-source: <commit-sha>` — the
commit its claims were checked against. Load-bearing corrections are recorded
inline with an auditable annotation (`> Drift-fix Dx — said X; corrected to Y;
slice-00 §z; re-confirmed <file:line> @HEAD`). OPEN items (counts that are
definitional or runtime-only) are carried verbatim, never smoothed. The
schema is defined in [frontmatter-schema.md](frontmatter-schema.md) and the
governing convention is `openrig-work/conventions/frontmatter-for-context/`.
## Derive the document inventory
At the named commit there are **26 Markdown files**: **14** under `architecture/`, **4** under
`ui/`, and **8** at the root of this tree (including both compatibility pointers). Reproduce
| [`architecture/architecture-rules-and-event-system.md`](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules (incl. **rule 15**, restore honesty), the RigEvent union + SSE delivery, intentional compatibility limits. |
| [`architecture/living-notes-review.md`](architecture/living-notes-review.md) | **Added v0.4.4** (not part of the original monolith): the Living Notes review surface — the one intent→plan→delivered projection, staged-approval locks, proof artifacts, freeze export, ranged media serving. |
| Boot, HTTP services and database wiring | [Daemoncore](architecture/daemon-core.md) |
| Harness adapters and native session continuity | [Adaptersandruntimes](architecture/adapters-and-runtimes.md) |
| Spec resolution, projection and launch | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| Durable queue and messaging state | [Coordination](architecture/coordination-primitive.md) |
| Workflow execution and watchdog policies | [Workflow runtime](architecture/workflow-runtime.md) |
| Queue observations and operator actions | [Mission control](architecture/mission-control.md) |
| Snapshot and restore | [Lifecycle](architecture/lifecycle-snapshot-restore.md) |
| Messaging, capture and transcripts | [Transportandtranscripts](architecture/transport-and-transcripts.md) |
| Project and repository scope | [Workspace](architecture/workspace-primitive.md) |
| Files, progress and steering | [Contentsurfaces](architecture/content-surfaces.md) |
| Review, proof and approvals | [Living notes and review](architecture/living-notes-review.md) |
| Plugin discovery, images and context | [Plugins, agent images and context packs](architecture/plugin-agent-image-context-pack.md) |
| Distribution, bundlesand bootstrap | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| Events and cross-cutting invariants | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) |
The UI half of the old `### UI architecture` section is now the **4 modules
under `ui/`** (see [`ui.md`](ui.md), also a redirect stub, and the `ui/` index
in [`./README.md`](./README.md)).
Older web UI links continue through [ui.md](ui.md). For terminal UI source, use the
[package entry points in the codemap](codemap.md#package-entry-points).
| `listInstalled` | `(binding)``:131` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)``:134` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding)``:137` | Deliver resolved startup files to the runtime. |
| `launchHarness` | `(binding, opts)``:147` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)``:153` | Probe whether the harness is responsive and ready. |
| `listInstalled` | `(binding)``:153` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)``:156` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding, sendInteractiveText?)``:160` | Deliver resolved startup files to the runtime. The startup orchestrator passes the optional third argument for Claude Code only, so Claude's `send_text` files go through the checked send described in `agent-spec-and-startup.md`. |
| `launchHarness` | `(binding, opts)``:170` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)``:176` | Probe whether the harness is responsive and ready. |
The interface also has two optional members: `claudeManagedLaunch` (`:145`)
and `skillTargetPath?()` (`:150`), implemented by `PiRuntimeAdapter`
(`pi-runtime-adapter.ts:118`) and overridden by `OmpRuntimeAdapter`
(`omp-runtime-adapter.ts:12`).
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
part of this contract — the contract docstring (`runtime-adapter.ts:137–142`)
states actions belong to the `StartupOrchestrator`*after*`checkReady()`. The
orchestrator delivery split is in `agent-spec-and-startup.md`.
### `launchHarness` opts and the fork seam
`launchHarness` opts is `{ name: string; resumeToken?: string; forkSource?:
ForkSource }` (`runtime-adapter.ts:147–150`).
ForkSource }` (`runtime-adapter.ts:172`).
> Drift-fix (precision) — `architecture.md` §4 said `launchHarness(binding,
| `GET /api/files/asset?root=…&path=…` | Raw asset; single byte-range support (`206`, invalid range `416`), five-minute cache. HTML and source files default to plain text and unknown types to `application/octet-stream`; `render=1` opts into HTML rendering. SVG is served as `image/svg+xml`. A `render=1` HTML preview or an SVG is served with no content-security or frame headers, so it can run script in the daemon's origin (`routes/files.ts:153–157`). |
| `POST /api/files/write` | Requires root, path, string content, expected mtime and hash. Stale reads return `409 write_conflict`; an absent write service returns `503`. |
The audit file is `~/.openrig/file-edit-audit.jsonl` by default
(`HOME`/`USERPROFILE` → `/tmp` fallback). **Append-only; never rotates at
v0.** A failed audit append throws `FileWriteError("audit_write_failed")`
but does **NOT** undo the landed write — the user's edit succeeded; an
audit-system flake must not silently revert canon. (The route still
surfaces it as a 500 so the failure is honest, not swallowed.)
Writes resolve actor/provenance through `resolveActorWithDeferral`. Missing
route dependencies return `503`; unknown roots and invalid paths return
`400`. A failed stat returns `404` on reads, lists and assets; on a write, a
missing or unreadable target returns `500 stat_failed`, because
`POST /api/files/write` only replaces existing files
(`domain/files/file-write-service.ts:120–128`; `routes/files.ts:264–265`). An
audit failure can return `500` even though the file write succeeded, so an
> `FilesWorkspace`); grep-confirmed no `path: "/markdown"` declaration
> anywhere in routes.tsx @HEAD.
| Field | Source and selection |
|---|---|
| `priorityStack` | Verbatim `STEERING.md`, mtime, and byte count. |
| `roadmapRail` | `roadmap/PROGRESS.md``[ ]`/`[x]` rows (`[~]` rows are not read); the first unchecked row is marked next. |
| `laneRails` | Every `PROGRESS.md` and `STEERING.md` under `delivery-ready`, using a depth-3 `ProgressIndexer` (lane ID `mode-N` for `mode-N/PROGRESS.md`, else the relative path); first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows. |
`progressRoutes()` (mounted `server.ts:508` as `/api/progress`):
`GET /tree` — the indexed hierarchy; 503 `progress_indexer_unavailable`
when unwired; 503 `progress_scan_roots_not_configured` + setup hint when
no roots.
| Suffix | Source |
|---|---|
| `/nodes` | `computeNodeHealthSummary`: node inventory aggregated across rigs, grouped by session/lifecycle status, with an attention-required count. |
| `/context` | `computeContextHealthSummary`: `context_usage` rows grouped by urgency and sample freshness. |
| `/version` | `getDaemonVersion`: the build-stamped version, else the daemon package version, else `unknown`; always `200`. |
| `/gateway` | The injected gateway subsystem's `status()`; `503` if unavailable. |
Unless `--id` is supplied, the CLI generates the create request's `qitem_id` (TEXT PK) before sending as `qitem-<UTC YYYYMMDDHHMMSS>-<16 hex>` (64 random bits) and prints it to stderr as a request identity, not proof of persistence; reuse that ID with `--id` and the unchanged payload after an unknown outcome, while daemon callers without an ID still use `newQitemId()` and its `qitem-<UTC YYYYMMDDHHMMSS>-<8 hex>` form.
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:33`;
| `route` | `state="done"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates new qitem at the route target (1-hop) |
| `route` | `state="handed-off"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates a new queue item at the route target (1-hop) |
| `annotate` | no queue mutation; audit record only |
| `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`) |
Each verb is one atomic daemon transaction: queue mutation via
`QueueRepository.updateWithinTransaction()` (which preserves Phase A
hot-potato closure validation — see `coordination-primitive.md` §3) + an
audit row in `mission_control_actions`+ a persisted
`mission_control.action_executed` event, all in one `db.transaction`. The
`packages/daemon/src/routes/bundles.ts` mounts create, inspect, install and history handlers
under `/api/bundles` (the mount is in `packages/daemon/src/server.ts`).
- **create** — detects a pod-aware RigSpec and uses `PodBundleAssembler`
(accepts an optional `rigRoot`); legacy create still uses
`LegacyBundleAssembler`.
- **inspect** — safely extracts the archive, detects `schema_version`; v2
returns `schemaVersion: 2`, `agents[]`, and integrity data; v1 returns the
legacy manifest shape.
- **install** — uses the full bootstrap plan/apply; bootstrap peeks the
manifest and routes deterministically to `pod_bundle` or `rig_bundle`.
Re-confirmed at source: `bootstrap-orchestrator.ts:134-143` — when
`sourceKind === "rig_bundle"` it unpacks to a temp peek dir, reads
`bundle.yaml`, and calls `parsePodBundleManifest(...)` to detect the
schema version before routing.
The create handler validates the source spec before comparing it with recorded live topology.
A divergent spec is refused unless the request explicitly allows drift; an allowed divergence
is recorded in the bundle's provenance notes. The exported spec is not silently rewritten
from live state.
## 3. `/api/up` source routing
| Source shape | Builder and output |
|---|---|
| Pod-aware rig spec | `packages/daemon/src/domain/pod-bundle-assembler.ts`: `PodBundleAssembler.assemble()` produces schema-version-2 metadata, vendors resolved agent trees, and rewrites agent references to local paths. |
| Legacy rig spec | `packages/daemon/src/domain/bundle-assembler.ts`: `LegacyBundleAssembler` (imported as `BundleAssembler` by the route) collects the referenced legacy packages and produces the legacy manifest shape. |
(`architecture.md` §6 "`/api/up`")
The pod builder collects culture, docs and startup material, preserves file bytes, and skips
vendoring the terminal sentinel as an agent. A copied file keeps its source mode with owner
Inspect also returns a behaviour view, `describeBundleBehaviour()` in
`packages/daemon/src/domain/bundle-behaviour.ts` (schema `openrig.bundle-behaviour/v1`). It
reads the extracted archive only, through `bundle-behaviour-inspect.ts` (regular UTF-8 files,
at most 1 MiB each and 8 MiB in total, opened without following links). It lists the team,
each seat's permission posture, the files seats are told to read, what else runs, where
content is written, literal outside addresses, needs, and what can't be known before launch.
A schema-1 archive gets `state: not_generated`. Posture comes from the member's or rig's
`permission_policy`: `builtin:yolo` is full bypass and `builtin:auto` is auto, both by launch
flag; `builtin:locked`, `builtin:standard` and `builtin:open` are configuration; a policy file
is read from the archive or marked unresolved; no policy means the product default. A rig-level
`non_interruptive` boolean is reported on each full-bypass Claude Code or Codex seat as
`nonInterruptiveDefault`, and `true` drops the Claude bypass first-run warning. The CLI prints
the view before `rig bundle install` and `rig up` as diagnostics only; it never changes the
request or the exit code.
## Source routing and bootstrap
`packages/daemon/src/domain/up-command-router.ts` classifies a source as
`rig_spec`, `rig_bundle`, `rig_name` or `topology`. It recognizes named rigs,
spec paths, `.rigbundle`, `.rigtopology`, and YAML with a top-level rigs list;
extensionless paths also have content detection.
`packages/daemon/src/routes/up.ts` then selects the execution path:
| Input | Consumer |
|---|---|
| Existing rig name | Existing-rig restore path; unarchived rigs of that name are preferred. |
| Single rig spec or bundle | `BootstrapOrchestrator.bootstrap()` in `packages/daemon/src/domain/bootstrap-orchestrator.ts`. |
| Topology manifest | `MultiRigLauncher`, with a single-rig bootstrap or remote-up leaf per entry. |
Topology plan mode is rejected. Placement belongs on the individual topology entries; a
top-level host flag is rejected. The selected topology parser/route also rejects nested
topologies, existing-rig-name entries and `.rigbundle` entries: topology entries are spec
paths only. This is narrower than the single-rig `up` surface.
Bootstrap distinguishes pod-aware and legacy specs, and inspects a bundle manifest to choose
`PodBundleSourceResolver` or `LegacyBundleSourceResolver` from
`packages/daemon/src/domain/bundle-source-resolver.ts`. Thus the router's
`rig_bundle` kind does not imply a legacy manifest.
For pod-aware specs, plan mode validates and runs preflight probes; apply delegates to
`PodRigInstantiator`. Plan mode still records a bootstrap run/result: it is not a pure
file read. Apply reports partial completion when nodes fail or require attention, preserving
the created rig identity rather than claiming every member launched. Apply's non-interruptive
choice is the request's, else the spec's top-level `non_interruptive`, else the
`launch.non_interruptive` setting.
### Durable install target
Bundle apply requires an explicit target root at the HTTP boundary. The CLI supplies the
current directory as the default for `rig up <file>.rigbundle` and for a GitHub link through
`rig up` or `rig bundle install`. `rig bundle install` with a local `.rigbundle` sends no
default, so apply without `--target` gets a 400. For a pod bundle,
`materializePodBundle()` copies the verified extraction into that durable root before
instantiation, so local agent references and relative working directories survive removal
of the extraction directory. It checks destination conflicts before copying, preserves
identical files, and refuses differing files or incompatible destination types.
When an unarchived or running team already has the bundle's rig name (`bundleInstallContext()`
in `packages/daemon/src/domain/bundle-install-context.ts`), pod-bundle apply refuses before
writing if any of them is running. If the target is that team's own install folder (its
`bundle.yaml` names the offered schema-2 bundle, or `my-bundle` or `github-bundle`, the default
names before 0.6.7, and it is the folder recorded in `rigs.install_root` when any of those teams
recorded one; `isExistingBundleTarget()`), bootstrap first
confirms the old sessions are stopped; materialization then copies each conflicting path to a
`bundle-backups/reinstall-*` folder under the OpenRig home, with a `RESTORE.json`, before
replacing it, and leaves unrelated files in place. Any other target keeps the conflict refusal.
The instantiator archives the stopped earlier generation. Pod-bundle apply records the
target's real path as the new rig's `install_root` (migration `096`).
Pod bundles containing service definitions are refused by bootstrap; their service path
requires a stable spec directory. Direct spec bootstrap has separate service prelaunch
handling. See [agent-spec-and-startup.md](agent-spec-and-startup.md) for instantiation.
## GitHub folder links and the bundle check
`packages/cli/src/lib/bundle-source.ts` lets `rig up`, `rig bundle create`, `inspect` and
`install` take a GitHub folder link (`isGitHubBundleLink()`). Only a credential-free
`https://github.com/owner/repo[/tree/<ref>/<folder>]` link is accepted. The ref is resolved to
the longest matching branch or tag, or taken as a full commit hash. The import:
- needs a healthy local daemon whose `selfHostId` matches the local origin;
- runs Git with credential helpers, hooks and non-HTTPS protocols disabled;
- shallow-fetches the exact commit into `bundle-imports/` under the OpenRig home, requires a
`rig.yaml` in the folder, and refuses symlinks or agent and package references that resolve
outside the checkout;
- posts the folder to `/api/bundles/create` with the source recorded in provenance, refuses a
response without a package digest or with a different source commit, and writes
`source.json` and `build.json` in the import folder.
`rig bundle check <folder>` (`checkBundleFolder()` in `packages/cli/src/lib/bundle-check.ts`)
is an advisory, file-read-only check against `openrig.bundle-standard/v1`; it makes no daemon,
launch or provider call. Its rules are `pod_aware_rig`, `readme_in_docs`, `referenced_files`,
`portable_agents`, `minimum_versions`, `configurations` and `credential_paths` (a filename scan
bounded at 10,000 entries that skips `.git`, `node_modules` and symlinks). `readme_coverage`
and `embedded_secrets` are always reported as not checked, and host-resolved resources are
reported as checked at launch (`host_resources`). Any finding sets exit code 1.
## Install checks and content routing
The install handler in `packages/daemon/src/routes/bundles.ts` obtains a source-path
lock, reads validated metadata, checks compatibility, and checks a declared rig name
against same-name rigs with running sessions before bootstrap; a stopped team of that name
doesn't block it. A conflict returns a 400 with `status: "not_attempted"`, the installed team
and the offered bundle, and three choices: use the existing team, stop it and retry to replace
it, or cancel. The compatibility override `skipVersionCheck` and name-conflict override `force`
are explicit request fields; neither skips the metadata pass, which always validates the
manifest.
`packages/daemon/src/domain/bundle-conflict-detector.ts` implements the rig-name check.
It is not a complete agent, port or filesystem collision audit; a missing rig name supplies
no name comparison. Target-file conflicts are handled separately by materialization.
Skipping these prechecks does not skip archive validation in bootstrap.
Declared content is routed by `routeBundleContents()` in
`packages/daemon/src/domain/bundle-content-routing.ts`, which unpacks the bundle once and
routes each kind independently. When it runs depends on the bundle schema:
- **Pod-aware (schema 2):** bootstrap runs it as `PodRigInstantiator`'s prelaunch hook, after
the rig record exists and before any seat launches, so every seat's first turn can see the
content.
- **Legacy (schema 1):** there is no hook, so the install route routes after bootstrap, and
only when the status is `completed`.
| Manifest content | Destination |
|---|---|
| `skills` | The OpenRig `packages` cache, stripping the legacy `packages/` prefix; this does not import a complete harness skill into the managed skill catalog. |
| `plugins` | The OpenRig `plugins` root, through local plugin references. |
| `workflow_specs` | `workflows/` beneath the resolved `workspaceSpecsRoot`. If that setting is empty, this kind is recorded as a routing failure. |
| `context_packs` | The configured `context.root`, matching context-library discovery. |
| `agent_images` | The OpenRig `agent-images` root; declarations address image directories containing a manifest. |
| `project` | Registered in the workspace catalog (`workspace.catalog_path`) with the rig associated, and its files placed under `workspace.projects_root/<id>` (`registerBundleProject()` in `packages/daemon/src/domain/workspace/project-registration.ts`). A conflict changes nothing and is a routing failure. |
Context packs are never merged into an installed pack of the same name: an identical one is
reported as `already_installed`, a different one is kept unchanged as `kept_existing`. After
routing, the live context library rescans, and a routed pack it can't load becomes a routing
failure.
The per-kind implementations are the `bundle-skills-router.ts`, `bundle-plugins-router.ts`,
`bundle-workflow-specs-router.ts`, `bundle-context-packs-router.ts` and
`bundle-agent-images-router.ts` files under `packages/daemon/src/domain/`. Routing is best
effort in both cases: the hook always lets the launch continue, and a failure never rolls back
or fails the install. Failures appear in the result's `routingFailures` and `warnings`, in a
`route_bundle_contents` stage for the hook, and in the install audit record.
Install audit records are appended best effort to `bundle-audit.jsonl` under the OpenRig
home; the history endpoint reads that audit. Bootstrap run state and action journals are
separate, repository-backed records.
## Legacy package install path
The legacy path remains executable, not just an archive parser.
`packages/daemon/src/routes/packages.ts` is mounted at `/api/packages`, and legacy
| Framed pack preview | `packages/daemon/src/domain/context-packs/bundle-assembler.ts`, `assembleBundle()`: pack/file headers, file metadata, token estimates and `missingFiles`. Missing members are reported and skipped; a failed read of a present member raises an error. |
| Plain file composition | The same file's `assemblePlainFiles()`: present files in declared order, separated by two newlines, without framing or trimming their bytes. |
| Addressed read | `packages/daemon/src/routes/context-packs.ts`, `/library/resolve-address`: longest matching pack-ref prefix, a declared file, then optional H2/H3 heading selection through `packages/daemon/src/domain/markdown-address.ts`. |
| Situation profile | `packages/daemon/src/domain/context-packs/profile-composer.ts`, `composeProfile()`: select atoms, close dependencies, order and resolve each piece. |
| Named profile | The same file's `composeNamedProfile()`: declared phases select atoms or supplied work/seat context, preserving phase IDs in the result. |
`ContextPackLibraryService.scan()` walks roots, parses each
`manifest.yaml`, replaces the in-memory index; collisions are
**last-wins** in discovery order (workspace > user_file > builtin).
Startup wires the builtin root (`../context-packs`, first), the configured
user-file root (`context.root`, default `$OPENRIG_HOME/context`), its
`system/` child as the canonical System World root, and the workspace-local
`<workspaceRoot>/.openrig/context-packs` root when present + distinct. A pack is
addressed by its path-like **ref** (e.g. `packs/compaction-restore`), which is
its unique identity; the opaque entry id is `context-pack:<ref>` (a UI routing
key). Colon-id `context-pack:<name>:<version>` addressing and the `/library/:id`
routes were removed in Slice-03 Atom 5. Resolution is by ref only through
`getByRef` and the read/delete/preview/pieces route family. Startup accepts
ordinary files only and rejects context-pack expansion; compose a durable ref
first, then use a dedicated delivery verb when delivery is intended.
Situation composition selects fresh atoms for `fresh`, fresh plus handover atoms for
`handover`, and post-compaction plus handover atoms for `post-compaction`. Runtime
selection is Claude or Codex, with `any` atoms applicable to either. Required atoms join
the selection; missing dependencies or runtime-incompatible dependencies fail composition.
The output is ordered by atom order, then ID. Profile-only atoms are excluded from ordinary
situation selection, but the dependency closure doesn't check that flag, so one can still join
as a dependency of a selected atom. Post-compaction composition skips an absent
`seat:RECAP.md` and reports it as skipped instead of failing.
/ session-id fallbacks) → `compliance_prompt` (read-depth audit); final
stage sets post-restore cooldown + clears the above-threshold latch.
`rig compact <session>` (`packages/cli/src/commands/compact.ts`) runs the same sequence
manually through `/api/compaction/trigger`, `cancel` and `state`, which use the terminal
bearer-token middleware; it passes every request through when no token is configured.
`--skip-map` skips the map requirement once, `--cancel` ends the preparation, and `--state`
shows the attempt, its expected map and its deadline. Ordinary approval prompts during preparation can be answered within that same deadline; OpenRig waits without answering them. A manual attempt has 180 seconds for
MID_WORK_PATTERNS)`); legacy mid-work check at `:666`.
3. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p` at every payload size → ~200ms delay → separate `C-m`. Bracketed paste preserves multiline input in supporting TUIs; the payload never enters a shell argument. A successful paste proves transport execution, not runtime consumption.
node join); no match = structured `harness_pin_unsatisfied` naming the
pin + every candidate's runtime. Explicit owner overrides are
reconciled too — an override can never silently defeat a pin. Static
check at instantiate for every pinned step; re-checked at each route.
`WorkflowRuntime` distinguishes these operations:
**Per-step `host:` pin (FR-3).** `local`/absent = full execution today.
A registry id validates against `~/.openrig/hosts.yaml` (daemon
read-only twin; unknown id = `host_not_registered` naming registered
ids) but a remote pin fails loud at INSTANTIATE with
`host_pin_remote_unsupported` naming the MH-3 boundary + workaround —
the queue is local-only until MH-3; no qitem is ever minted into a
queue that cannot route it, and there is no silent local fallback.
| Operation | Meaning |
|---|---|
| `route` | Replace a current packet's owner while retaining the step. This is not step advancement. Multiple frontier packets require an explicit packet selection. |
| `resume` | Redrive failed work to a newly resolved owner, recording the optional `--decision` text. A serial instance must be `failed` (`instance_not_failed`). Resume re-resolves the owner rather than copying it, re-bases `hops_baseline`, increments `resume_count`, and closes that occurrence's open exception items. Dependency failures use occurrence identity; multiple unresolved failures require selection. Identical occurrence/decision replay returns the recorded redrive, while changed decision bytes conflict. |
| `abort` | Explicitly stop unfinished work with an actor and reason: cancels every frontier packet, records a `failed` trail row per packet, sets `aborted`, and emits `workflow.failed` with reason `aborted: <reason>`. Refused only for `completed` or `aborted` instances. |
| `continue` | Read-only inspection of instance, trail, guidance, reconciliation, frontier, failures, unknowns, and boundary obligations. It does not mechanically advance a step. |
**Structured step-level `gate:` (FR-5 — the socket; WF-5 owns
semantics).** Singular per step, closed keyset `{target, summary,
evidence_ref}`. HUMAN target (the shipped human-seat predicate) →
compiles to a human-routed item (tier `human-gate` + summary +
evidence_ref — the shipped 0.4.4 write path), resolved by the shipped
`resolve` verb; HANDLER-ROLE target → an ordinary agent item to the
role's resolved seat. Routing INTO a gated step creates the gate item
as the frontier packet and parks the instance `waiting`; resolve/close
continues the flow from that step (the WF-1 unpark — no restart). A
gated ENTRY step parks from birth.
## Deadlines and exceptions
**Dispositions (FR-4) — the inert third state is dead.** The legacy
step `gates: [...]` string list is REMOVED at parse
(`spec_gates_removed`, what/why/fix naming the new `gate:` object);
`next_hop.mode: prefer` is REMOVED at parse
(`spec_prefer_mode_removed` — it never had distinct behavior).
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time; authors may declare relative to `workspaceRoot` in YAML |
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time (`domain/rigspec-schema.ts:361–363`); authors may declare it relative to `workspaceRoot` in YAML |
| `defaultRepo?` | string | active repo when no env override / cwd match |
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
`WorkspaceKind` is a closed 5-member union: `user`, `project`, `knowledge`,
`lab`, `delivery`.
`WorkspaceKind` is a closed union of **5** members
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (DELETED per SC-18; the Mission Control *system* lives at daemon/PL-005, not a UI destination) | `:440-444` |
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (the Mission Control *system* lives in the daemon at `/api/mission-control`, not as a UI destination) | `:496–500` |
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into the Project workspace overview tab) | `:526–530` |
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | absent: **0** (`grep -c 'path: "/missions"' packages/ui/src/routes.tsx`) |
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md) | absent: **0** (`grep -c 'path: "/markdown"' packages/ui/src/routes.tsx`) |
# Arteries: where a small change has a large effect
## Read this first: this map is not the territory
- **This page is a zoomed-out view, not a description of OpenRig.** It names a few areas where
small changes caused large regressions before. OpenRig is much larger than this page. Code,
behaviour and risk that are not listed here exist, and some of it is riskier than anything below.
- **It is incomplete by construction.** A pull request is not safe because its files are missing
from this list.
- **It may be stale.** It was checked against commit `e8f0ab34`. Files and dependencies move. If
this page and the code disagree, the code is right; fix the page.
- **Use it to change altitude, then go to the ground:** read the code, run the commands, check the
behaviour. When you are lost, go and look rather than leaning on this page.
## The arteries
Paths are relative to `packages/`. The key files are entry points, not the full extent of each
area.
| Artery | Key files | What depends on it | Past regressions and lessons |
|---|---|---|---|
| **Message delivery to seats** | `daemon/src/domain/session-transport.ts`, `daemon/src/domain/seat-delivery-guard.ts`, `daemon/src/adapters/tmux.ts` | Every `rig send`, queue wake, nudge, watchdog wake and handoff | #150 (the fix for #142) stopped typing into a bare shell. It then refused managed Codex seats (fixed by #171 before release) and every Claude seat launched with an explicit permission mode, which shipped in 0.6.2 as #197 (fixed by #220). The delivery guard's rule that a name must resolve to exactly one target, added in #109 (0.6.0), was involved in #141 (fixed by #151), the delivery half of #174 (fixed by #181) and #188 (fixed by #189). Since #310/#327, a Claude seat whose runtime can't be observed still receives ordinary messages with a warning; only a plain idle shell or the wrong recipient is refused. |
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/adapters/pi-runtime-adapter.ts`, `daemon/src/domain/seat-launch-environment.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode, plus the non-interruptive, `permission_policy`, effort, kernel operational-authority and team permission-default settings that each launch and resume command carries | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154). #225 fixed which config home is selected, and #565 writes the onboarding and trust bootstrap into that home. Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The readiness window defaults to 30 seconds and can time out under load (#182); since #643 it is configurable as `runtime.readiness_timeout_seconds` (env `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`, 1–600). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). #618 moved non-managed Claude, Codex and Pi launches behind the seat launch environment, and #628, #638 and #666 followed within days. Pi launch and resume carry `--thinking` from the seat's resolved effort (OMP does not). |
| **Queue claim, pickup, wakes** | `daemon/src/domain/queue-repository.ts`, `daemon/src/domain/queue-pickup.ts`, `daemon/src/domain/queue-wake-ladder.ts`, `daemon/src/routes/require-sender-identity.ts`, `cli/src/client.ts` | All durable work between seats | After a slow `/healthz`, the CLI host-qualified a local sender and claims failed (#131, fixed by #135). Done-and-closed items read as stalled forever (#164, fixed by #176). |
| **Rig identity: same names, archived and deleted rigs** | `daemon/src/domain/rig-repository.ts`, `daemon/src/domain/rig-lifecycle-service.ts`, `daemon/src/domain/rig-teardown.ts`, `daemon/src/domain/seat-handover-service.ts`, `daemon/src/domain/running-name-guard.ts` | Seat resolution, `rig remove`, restore, importing a rig from YAML | Archived rigs still matched seat references, and `rig remove` killed another rig's live seat with the same name (#174, fixed by #181). Replacing stopped same-name rig generations on YAML import followed in #196. |
| **Projection of skills and files** | `daemon/src/domain/rigspec-instantiator.ts`, `daemon/src/domain/projection-planner.ts`, and each runtime adapter's `project()` (Claude Code, Codex, Pi, OMP, stub) | What every agent is given at start | Codex seats skipped skills that a Claude sibling had already projected (#159, fixed by #162). File-shaped entries failed with ENOTDIR (fixed by #185). |
| **Native process observation** | `daemon/src/domain/native-process-lineage.ts` (parses `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`) | Liveness, identity verdicts, delivery checks | Processes whose command name contains a space were dropped (#134, fixed by #82). Non-English locales broke the date parsing (fixed by #239, which runs `ps` with `LC_ALL=C`). #220 also changed this file to recognise managed Claude seats behind launch wrappers. Claude identity proof (wrappers, launcher shims, conversation rotation, effort, version-named binaries, launch `--settings`) changed ten more times after 0.6.3. |
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and #155 and #195 landed as 090 and 091. `migrate.ts` applies by migration name in name order, so a duplicate number is not skipped, but its order relative to the other migration changes; 093 is unused at `e8f0ab34`. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Restore and recovery** | `daemon/src/domain/restore-orchestrator.ts`, `daemon/src/adapters/{claude,codex,pi,omp}-resume.ts`, `daemon/src/domain/reconciler.ts`, `daemon/src/domain/restore-check-service.ts`, `daemon/src/domain/startup-orchestrator.ts`, `daemon/src/adapters/tmux.ts` | Reboots, power loss, daemon restarts | Detached seats kept stale tmux pane ids after a reboot (#141, fixed by #151 in `tmux.ts`). `restore-check` read queue files and Claude hooks from a fixed shared-docs path (#130); #139 made it use the configured root, and #328 replaced the queue-file check with a probe of the daemon's queue store and checks the selected Claude hooks. After a fresh relaunch, a stale Codex restore attention flag never clears (#116). Full-restore recovery of OMP seats is open (#41). |
## How reviews use this page
- **Find where the effect lands.** A reviewer notes which artery a pull request touches, if any,
and what depends on it.
- **At an artery, review the effect, not only the diff.** Whatever the diff size, the reviewer
states the downstream effects and checks behaviour with a scratch reproduction or a test.
- **Away from the arteries,** small pull requests keep a normal review. Scrutiny goes where the
effect is.
## The scenario rule
**A pull request that touches an artery adds or extends a stub-agent scenario, or says in its
description why it cannot.** Reviewers check for one or the other. Scenarios live in
`packages/test-system/scenarios/`; [ARCHITECTURE.md](../../ARCHITECTURE.md#a-scenario) says how to
write and run one, and [test-layers.md](test-layers.md) says what each test layer can and cannot
catch.
The goal is that every artery has at least one scenario whose seeded regression fails, so a small
change to an artery meets a real end-to-end check before it merges, not only a reviewer's reading.
### Scenario coverage today
At commit `e8f0ab34`. For why the stub cannot reach some of these yet, see "What a stub can and
cannot prove" in [test-layers.md](test-layers.md).
- **Queue durability across a daemon restart: covered.** CI runs
`queue-baton-survives-restart.yaml` healthy, then with a seeded `baton-drop` fault that must
fail, then healthy again (#243). It runs `rig queue claim` and checks the destination after the
restart, but pickup, wakes and claim-identity edge cases (such as the #131 sender case) have no
scenario yet.
- **Delivery (the #197 class): not covered.** The stub runtime has no input behaviour, so it cannot
exercise delivery to a managed wrapper; `send-verify-means-rendered.yaml` is not admitted to CI
for that reason (`packages/test-system/ci/README.md`).
- **Addressing and rendered delivery: partial.** CI runs the transcript and capture stub scenarios
in passing form, and the capture scenario catches a seat resolved by member-name prefix.
`send-renders-in-addressed-pane.yaml` and `down-stops-every-seat.yaml` run with the local runner
only. `stub-cli-journeys.integration.test.ts` checks that a verified send lands in the addressed
pane and not its sibling. None of these proves a seat consumed a message.
- **Restore (the #141 class): not covered.** The scenario runner does not bind the `restore` verb
yet; it fails with `UnboundActionError`.
- **Rig identity (the #174 class): not covered.** No library scenario sets up a same-name archived
or removed rig.
- **Projection (the #159 class): not coverable with `runtime: stub`,** which projects skills into its
own `.openrig/stub/skills/` directory. Use adapter-level unit tests.
- **Native process observation: unit tests only** (for example
`packages/daemon/test/codex-process-observation.test.ts`, `native-ps-locale.test.ts` and
`send-runtime-not-running.test.ts`).
- **Migrations: unit guards only** (`migration-fixture-parity.test.ts`,
`startup-migrations-mirror.test.ts`). Every scenario starts from an empty database, so upgrading
an existing database is not covered.
- **Launch:** every scenario's `up` goes through the real `rig up`, instantiation and startup
orchestration with stub seats. The stub adapter types its runner directly; Claude Code, Codex
and Pi seats launch through the seat launch environment (`seat-launch-environment.ts`), which
no scenario reaches. Real Codex launch and handover run only in two opt-in daemon tests
(`OPENRIG_E2E_REAL_CODEX=1`, which needs tmux, `codex` and a Codex login). The CI containers
have no provider CLIs or credentials.
## Regression log
When a regression reaches a release, add one line: date, issue, artery, and which layer missed it
(the layers are named in [test-layers.md](test-layers.md)). Each line should end up as a scenario.
- 2026-09-30, #197 (0.6.2), delivery: review saw a small diff in isolation; CI had no managed
explicit-mode Claude seat; the project's own day-to-day seats use the default launch path.
- 2026-09-29/30, #141, #174, #188 (after 0.6.0), delivery and rig identity: reviews of #109 did not
see the downstream effect of the exactly-one-target rule.
- 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.
This is a **map of territory**, not a content dump. It tells you which
as-built module answers a question and which `packages/*/src` root that
module is grounded in. The deep content lives in the modules; the code lives
in the source roots; this file points at both.
The paths and named entry points below were checked at main
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. This is a navigation map, not a replacement for
reading the implementation. Linked documents retain their individual verification stamps.
[README.md](README.md) lists the complete document tree and commands to derive its inventory.
> The old codemap was a 425-line file-by-file `Exports:`/`Related:` flat dump
> that drifted fastest (its own header carried a stale v0.2.0 footprint and an
> internally-contradictory command-group count) and never fit a context
> window. That anti-pattern is retired (slice-08 Q5, founder-ratified). The
> genuinely-useful part — the source-root index — is preserved below as the
> source-root pointer table; the per-file transcription is not.
## Package entry points
## (a) Product frame
Paths in backticks are relative to the repository root.
OpenRig is a local control plane for multi-agent coding topologies — a
multi-agent harness that manages your Claude Code and Codex sessions as a
single system. The daemon (`@openrig/daemon`) is the framework-free
SQLite-backed core; the CLI (`@openrig/cli`), UI (`@openrig/ui`), and MCP
server all sit on top of it.
> Source-verified at HEAD `7eaf524c` (`v0.3.1-6-g7eaf524c`); package version
> **0.3.1**, 6 unreleased 0.3.2 commits on HEAD, no `v0.3.2` tag. Frame
> sentence triangulated against `architecture/daemon-core.md` §1 +
> `openrig-internal/product/positioning.md` ("a multi-agent harness, a local
> control plane that manages your Claude Code and Codex sessions as a single
> system").
## (b) Module map
One row per module. "Reach for this when…" mirrors each module's actual
`applies-when` frontmatter.
### architecture/
| Module | One-sentence summary | Reach for this when… |
|---|---|---|
| `daemon-core.md` | The framework-free SQLite-backed core the CLI/UI/MCP all sit on. | You need to know how the daemon boots, how `createDaemon` wires the dependency graph, the SQLite schema/migration set, or the route-mount surface. |
| `adapters-and-runtimes.md` | The five-method runtime-adapter contract + the resume-honesty layer. | You need the runtime-adapter contract, how OpenRig launches/resumes a Claude Code, Codex, or terminal harness inside tmux, or how the daemon honestly assesses whether a harness actually resumed vs fresh-launched. |
| `coordination-primitive.md` | PL-004 Phase A: the SQLite-canonical durable-work layer (`/api/stream`, `/api/queue`). | You need the stream/queue/inbox/outbox tables, the hot-potato closure contract, the transactional handoff guarantee, or where queue closure is enforced. |
| `workflow-runtime.md` | PL-004 Phase D: turns an intended sequence of work into durable SQLite state. | You need workflow specs cache, instance state, step trails, the transactional-scribe projection contract, or the watchdog policy set including workflow-keepalive. |
| `mission-control.md` | PL-005: the daemon-backed queue-observability surface inside the existing shell. | You need the seven views, the seven write verbs, the action audit table, the bearer-token middleware, or how queue observability maps to PL-004 sources. |
| `agent-spec-and-startup.md` | How authored YAML specs become a resolved, launched, identity-addressable topology. | You need the AgentSpec/RigSpec/pod-aware reboot types, profile resolution + additive startup layering, the StartupOrchestrator delivery split, or how whoami/materialize/bind/adopt resolve identity. |
| `lifecycle-snapshot-restore.md` | The durable-state half of `down → up → handoff`. | You need how OpenRig captures a snapshot, restores a rig (resume vs rebuild vs fresh), enforces restore honesty, consults live continuity, or how restore-check / restore-packet readiness probes work. |
| `transport-and-transcripts.md` | The communication-and-history layer; tmux is transport, not truth. | You need how rig send/capture/broadcast works, pipe-pane transcript capture + search, durable SQLite chat, what `rig ask` gathers, or the MCP-tool-name vs tmux-metadata-key distinction. |
| `workspace-primitive.md` | PL-007: the typed declaration of where a rig's work lives. | You need how a rig declares a typed workspace (root/repos/defaultRepo/knowledgeRoot), how it persists/resolves into whoami / node-inventory, how per-item `target_repo` scope is validated, or how the file-backed missions/slices tree is indexed and projected into Project. |
| `content-surfaces.md` | The operator-allowlisted, filesystem-canonical read/write layer Project/Steering sits on. | You need how the file browser enforces path safety, how atomic conflict-checked writes + the JSONL edit audit work, how the PROGRESS.md tree is indexed, or how the one-screen Steering surface is composed. |
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. |
### ui/
| Module | One-sentence summary | Reach for this when… |
|---|---|---|
| `shell-and-routing.md` | The shell-first, route-first, primitive-driven operator surface. | You need how the UI shell (rail / Explorer / center workspace / drawer / preview stack) is assembled, the actual route tree the shipped UI mounts, or how the shared detail drawer and event consumption work. |
| `topology.md` | The operator's live picture of host → rig → pod → seat. | You need how the topology surface is built — the host hybrid graph, the table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, and the navigation/overlay contracts. |
| `project-and-for-you.md` | The three operator-facing destination surfaces (For You / Project / Dashboard). | You need how the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, and the Dashboard landing on the vellum brand system are built. |
| `library-specs-and-design-system.md` | The Library (`/specs`) destination + the design-system pointer. | You need how the Library UI is assembled — the spec/skills/plugins surfaces, the spec-review + spec-library + live-identity flows that feed it — or where the canonical visual/design-system spec lives. |
### root
| Doc | One-sentence summary | Reach for this when… |
|---|---|---|
| `cli-reference.md` | The full `rig` CLI surface, kept as one doc. | You need the exact rig CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. |
| `frontmatter-schema.md` | The frontmatter convention these docs follow + the as-built-unique field. | You are authoring or updating a doc under `docs/as-built/`. |
| `../DESIGN.md` | The canonical visual / brand / design-system spec (repo `docs/` root). | You need the visual system, brand identity, or design-system tokens. (Stays at root by design; pointer only.) |
## (c) Structural relationship diagram
Built from each module's `siblings` / `prerequisite-reads` frontmatter.
`daemon-core.md` is the spine (prerequisite for almost every architecture
module); `shell-and-routing.md` is the UI spine.
```
README.md (entry — prerequisite for all)
│
┌───────────────┴───────────────┐
▼ ▼
architecture/ ui/
│ │
daemon-core.md ◀── (prerequisite spine for the column below)
│ │ │ │
│ │ │ └──▶ adapters-and-runtimes.md
│ │ │ (five-method contract + resume honesty;
│ │ │ consumed by agent-spec startup orchestration)
The daemon's route handlers obtain domain services from request context. `startup.ts` constructs
those services, repositories and runtime adapters; `server.ts` wires them into the application.
CLI and TUI clients use the daemon's HTTP projections. Package boundaries and the development
versus bundled paths are described in [ARCHITECTURE.md](../../ARCHITECTURE.md).
Module → primary `packages/*/src/...` roots. This replaces the old per-file
`Exports:`/`Related:` dump: it points at the code, it does not transcribe it.
Source-grounding for each claim lives inside the module with file:line cites.
## Runtime and state modules
| Module | Primary source roots |
Each row names an existing module and a small set of source starting points. Directories group
related code; they are not an exhaustive dependency list.
| Question | Module | Source entry points |
|---|---|---|
| How are the daemon, database and routes assembled? | [Daemon core](architecture/daemon-core.md) | `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/db/`. |
| How do runtime launch, projection and resume work? | [Adapters and runtimes](architecture/adapters-and-runtimes.md) | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/`, `packages/daemon/src/domain/native-resume-probe.ts`. |
| How do authored specs become launched agents with identity? | [Agent spec and startup](architecture/agent-spec-and-startup.md) | `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/startup-orchestrator.ts`, `packages/daemon/src/domain/whoami-service.ts`. |
| Where are durable work, delivery and handoff handled? | [Coordination](architecture/coordination-primitive.md) | `packages/daemon/src/domain/queue-repository.ts`, `packages/daemon/src/domain/stream-store.ts`, `packages/daemon/src/domain/inbox-handler.ts`, `packages/daemon/src/domain/outbox-handler.ts`. |
| How does a workflow retain progress and resume? | [Workflow runtime](architecture/workflow-runtime.md) | `packages/daemon/src/domain/workflow-runtime.ts`, `packages/daemon/src/domain/workflow-projector.ts`, `packages/daemon/src/domain/policies/`, `packages/daemon/src/routes/workflow.ts`. |
| How are queue observations and actions exposed? | [Mission control](architecture/mission-control.md) | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/routes/mission-control.ts`. |
| What is captured and checked during restore? | [Lifecycle, snapshot and restore](architecture/lifecycle-snapshot-restore.md) | `packages/daemon/src/domain/snapshot-capture.ts`, `packages/daemon/src/domain/restore-orchestrator.ts`, `packages/daemon/src/routes/restore-check.ts`. |
| How do messages and transcript reads reach sessions? | [Transport and transcripts](architecture/transport-and-transcripts.md) | `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/routes/transport.ts`. |
| Where do workspaces, projects and repository scope resolve? | [Workspace](architecture/workspace-primitive.md) | `packages/daemon/src/domain/workspace/`, `packages/daemon/src/domain/current-work.ts`, `packages/daemon/src/routes/projects.ts`. |
| How are files, progress and steering read or written? | [Content surfaces](architecture/content-surfaces.md) | `packages/daemon/src/domain/files/`, `packages/daemon/src/domain/progress/`, `packages/daemon/src/domain/steering/`. |
| How are review inputs, proof and approvals composed? | [Living notes and review](architecture/living-notes-review.md) | `packages/daemon/src/domain/review/`, `packages/daemon/src/domain/scope/`, `packages/daemon/src/routes/review.ts`. |
| Where are plugins, agent images and context packs discovered? | [Plugins, images and context packs](architecture/plugin-agent-image-context-pack.md) | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/agent-images/`, `packages/daemon/src/domain/context-packs/`. |
| How are reusable bundles assembled and installed? | [Packaging, bootstrap and bundles](architecture/packaging-bootstrap-bundles.md) | `packages/daemon/src/domain/pod-bundle-assembler.ts`, `packages/daemon/src/domain/bootstrap-orchestrator.ts`, `packages/daemon/src/routes/bundles.ts`, `packages/daemon/src/routes/up.ts`. |
| Where are event types, publication and SSE handled? | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) | `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/event-bus.ts`, `packages/daemon/src/routes/events.ts`. |
For an execution projection, also read `packages/daemon/src/domain/execution-view.ts` and
`packages/daemon/src/domain/view-projector.ts`. The terminal-side consumer starts in
`packages/tui/src/execution/`. These sit alongside the queue and workflow sources above.
### Newer entry points
These features were added after the module pages above were last verified. Each module's own
stamp says whether it already covers them; until then, start from the source named here.
| How does a GitHub folder link become a bundle source? | `isGitHubBundleLink()` and the bundle source in `packages/cli/src/lib/bundle-source.ts`, used by `packages/cli/src/commands/up.ts` and `packages/cli/src/commands/bundle.ts`. | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| What does `rig bundle check` read? | `checkBundleFolder()` in `packages/cli/src/lib/bundle-check.ts`: advisory file reads against `openrig.bundle-standard/v1`, with no daemon, launch or provider call. | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| How does a member's `permission_policy` become a launch posture, including `builtin:auto`? | `builtinLaunchPosture()` and `resolvePermissionPolicyAttachment()` in `packages/daemon/src/domain/permission-policy/policy-ref.ts` (`builtin:yolo` is full bypass, `builtin:auto` is auto, the other built-ins are the floor), bound per seat in `packages/daemon/src/domain/rigspec-instantiator.ts`. `packages/daemon/src/domain/bundle-behaviour.ts` is the read-only preview of the same mapping for an unopened bundle. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What does a non-interruptive launch add? | `nonInterruptiveArgs()` in `packages/daemon/src/adapters/non-interruptive.ts` (per-launch arguments only; it never changes permissions or native settings files). The separate instance-wide YOLO setting, off by default, is in `packages/daemon/src/adapters/yolo-mode.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch arguments does a kernel seat get? | `operationalLaunchArgs()` and `KERNEL_CLAUDE_ALLOW` in `packages/daemon/src/adapters/kernel-authority.ts`; `NativePermissionStore.launchOverride()` in `packages/daemon/src/domain/native-permission-store.ts` decides it per launch (only the rig named `kernel`, with no explicit seat choice, permission policy or Codex profile). | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch default does a seat in any other rig get? | Under the same conditions `launchOverride()` sets `teamPermissionDefault`: a Claude seat at the floor posture gets `TEAM_CLAUDE_ALLOW` and `TEAM_CLAUDE_ASK` from `operationalLaunchArgs()` in `packages/daemon/src/adapters/kernel-authority.ts`, whose `claudeCommandSettings()` hands the kernel or team lists to the session-only Bash hook `packages/daemon/assets/claude-team-permissions.cjs`, which returns the allow or ask decision; a workspace-write Codex seat gets `--add-dir` roots from `prepareCodexTeamWorkspace()` in `packages/daemon/src/domain/codex-team-workspace.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| How does `rig seat continue` finish a fresh or Codex-fork startup that stopped before its context was delivered? | `continueStartup()` in `packages/daemon/src/domain/seat-lifecycle-service.ts`, `canContinueStartup()` and `readPendingStartupContext()` in `packages/daemon/src/domain/startup-orchestrator.ts`, `captureForkIdentity()` in `packages/daemon/src/adapters/codex-runtime-adapter.ts`, the route in `packages/daemon/src/routes/seat.ts`, and the command in `packages/cli/src/commands/seat.ts`. | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| How does `rig context work-install` choose a project and position? | `resolveWorkPosition()` in `packages/cli/src/lib/work-install.ts`, `packages/cli/src/commands/context.ts`, and `packages/daemon/src/domain/workspace/project-catalog.ts`. | [Workspace](architecture/workspace-primitive.md) |
| Where do rosters come from? | `packages/cli/src/commands/roster.ts`: `rig roster list`, `show` and `find` read JSON roster files from `<workspace.root>/rosters` (or `--folder`) and are read-only. | [Agent spec and startup](architecture/agent-spec-and-startup.md), "Specialist rosters" |
| What telemetry can be read? | `packages/daemon/src/domain/finite-telemetry.ts` (the `events`, `queue-transitions` and `tenures` streams), `/api/telemetry` in `packages/daemon/src/routes/telemetry.ts`, and `packages/cli/src/commands/telemetry.ts`. | [Architecture rules and events](architecture/architecture-rules-and-event-system.md), "Telemetry read surface" |
| Contributor setup and a small change recipe | [CONTRIBUTING.md](../../CONTRIBUTING.md), [ARCHITECTURE.md](../../ARCHITECTURE.md). |
| What depends on a sensitive source path | [arteries.md](arteries.md). |
| Unit, route, scenario, package and CI coverage | [test-layers.md](test-layers.md), `packages/test-system/README.md`, `.github/workflows/tests.yml`. |
| Exact CLI syntax | [cli-reference.md](cli-reference.md), then `packages/cli/src/index.ts`, its command module and current command help. |
# Test layers: what to run before you push, and what each layer proves
> **This page is a map, not the territory.** It was checked against source at `e8f0ab34`
> and it will drift. It is also incomplete on purpose. Before you rely on a command or a
> claim here, read the script it names or run it. If a behaviour is missing from this page
> (or from [arteries.md](arteries.md)), that tells you nothing about whether a change to it
> is safe.
## Short version
For every change, run what the pull-request template asks for:
```sh
npm run build
npm run lint # typecheck only; there is no formatter or style linter
npm test # = npm run test:repo && npm run test:workspaces
```
Then add the layers your change calls for, using the ladder below. CI also runs a
network-denied macOS test pass and a containerised installed-package scenario on every PR
to `main`. In the PR description, say which layers you ran and which you could not run.
## The ladder
| Layer | Command | What it proves | What it cannot prove | When to run it |
|---|---|---|---|---|
| Typecheck | `npm run lint` | Builds the daemon, then `tsc --noEmit` passes for daemon, ui, cli and tui | Any runtime behaviour. Formatting. | Every change |
| Build | `npm run build` | All four workspaces compile (the UI also runs `vite build`) | That the *published* package is complete (see Packaging) | Every change |
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-first-run-references.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` (skill homes, generated copies, layout and digests) and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| Package suites | `npm run test:workspaces`. One package: `npm run test -w packages/cli`. One file, from the repo root: `npx vitest run packages/cli/test/<file>.test.ts` | Vitest unit and integration tests for daemon, cli and tui. Some of them start real scenario daemons, tmux sessions and stub runners. | The installed package. Real Claude Code or Codex. CI's sandbox conditions (see CI). | Every change (`npm test` covers it) |
| Web UI suite | `npm run test:ui` | jsdom unit tests for `packages/ui` | Real browser rendering | When you touch `packages/ui`, or a daemon API the UI reads. The web UI is in maintenance mode, so this suite is advisory locally and is not part of `npm test`. |
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Shipped skill homes are publishable, generated copies match their homes, all edges match their recorded hashes, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | Edit a shipped skill's one in-repo home, run `npm run mirror-skills` to regenerate its other copies and refresh digests, then run the checks and commit the result. The apply reads nothing outside the repository. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| Local gate | `npm run gate` | Lint plus `npm test` on a clean tree, tied to `HEAD`, one run per machine at a time, with a JSON receipt | Anything that lint and `npm test` don't already prove | Optional. Useful when several agents share one machine, or when you want a receipt tied to a commit. |
| Packaging | `npm run build:package`, `npm run test:tui-package`, `bash scripts/smoke-fresh-install.sh` | The publishable `@openrig/cli` assembles, the packed TUI launches, and a fresh install starts a daemon | Behaviour beyond startup | When you touch dependencies, bin entries, build scripts, or files a package ships |
| Stub scenarios, host mode | `node --import tsx packages/daemon/scripts/run-scenarios.mjs [scenario.yaml …]` | A scenario's steps and assertions pass against a scratch daemon, real tmux and `runtime: stub` seats | The installed package. Seeded-regression pairing. Everything under [What a stub can and cannot prove](#what-a-stub-can-and-cannot-prove). | While you write or debug a scenario |
| Stub scenarios, installed package in a container | CI job `installed-scenario`. Before pushing: `DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh --remote …` | A clean Linux install starts. A claimed queue item survives a daemon restart. A planted fault is caught at the intended assertion. Two stub seats' scripted output reads back through `rig transcript` and `rig capture` (passing form only). | Native runtimes. Most command families (see [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario)). | Changes that can affect daemon startup, packaging, queue durability or the scenario harness |
| Live-model evals | `npm run eval -w packages/daemon -- --provider rig …` | Whether a real Claude Code seat runs the expected `rig context get <entry>` for natural prompts | Determinism. Codex. Anything without a case. | Changes to shipped skills or context entries meant to steer agents. **Uses provider credits.** |
| Testbed runbooks | `docker/testbed/runbooks/` (manual) | The testbed image itself works as a test environment | Product behaviour | Only when you change the testbed image or the container plumbing |
Some package tests are gated. `OPENRIG_E2E_REAL_CODEX=1` runs two daemon e2e tests against a
real Codex (they also need tmux, `codex` and `~/.codex/auth.json`, and they use provider
credits). `OPENRIG_REAL_CLAUDE_INTEGRATION=1` enables only a placeholder: it requires
`OPENRIG_PARENT_NATIVE_ID` and checks its format, and no real-Claude fork test exists yet.
Without tmux, some tmux tests skip and others (such as `transcript-seat-env-native.test.ts`)
fail. The CLI's release-tag tests skip without local tags unless `CI=true`, so a green local
run without tags has skipped them. CI installs tmux and fetches full history.
The TUI has its own vitest suite (`npm run test -w packages/tui`, which builds the TUI first)
and runs in the `tui` leg of `package-tests`; `npm run test:tui-package` launches the packed
TUI in demo mode. The `tui_socket` scenario surface exists, but the only scenarios that use it
are not yet runnable checks.
### Gotchas that cost people time
- **Running inside a seat.** The root `vitest.config.ts` makes a root-level file run use its
package's config. The cli, daemon and TUI configs all run the shared root
`test/hermetic-env.setup.ts` before any test file. It clears the inherited `OPENRIG_*` and `RIGGED_*` instance selectors (connection,
database, workspace and topology roots, seat identity, bearer tokens), forces a fixture
`OPENRIG_HOME` and fails if the home is not fixture-scoped. It also wraps `fetch`, so a real
request to anything other than a loopback ephemeral-port fixture or a registered target fails.
It points `HOME` and the XDG directories at a fixture user home and clears `CODEX_HOME` and
`CLAUDE_CONFIG_DIR`. `OPENRIG_E2E_REAL_CODEX=1` keeps native homes and binaries only for
`seat-handover-model-fidelity-e2e.test.ts` and `seat-lifecycle-set-model-resume-e2e.test.ts`;
all other files remain isolated. The scenario scaffold always uses fixture homes and runtime stubs.
The two native files rely on Vitest's default per-file isolation; runs without it are unsupported.
Scenario scaffolds scrub inherited provider roots but preserve those passed in an explicit `baseEnv`.
A test can still set its own
overrides after setup. The UI suite (jsdom) uses its own `packages/ui/test/setup.ts`.
- **Stale vendored daemon.**`npm run build:package` leaves an assembled copy of the daemon at
`packages/cli/daemon/` (gitignored). If you change daemon source afterwards,
`scripts/check-cli-daemon-freshness.test.mjs` inside `test:repo` can fail on the stale copy.
Re-run `npm run build:package` or delete `packages/cli/daemon/`. `npm run gate` deletes it for you.
- **Local passes, CI fails.** CI runs the package suites with a scratch `HOME`, no
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.
- **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.
## What CI runs on your pull request
`.github/workflows/tests.yml` runs on pull requests to `main` and on manual dispatch, all on
Node 22. Each suite is its own job, so one failure doesn't hide the others. No provider
credentials are supplied.
| Job | Runner | What it does |
|---|---|---|
| `build-and-package` | ubuntu | `npm ci` and `npm run build`. Then `npm pack --dry-run` for cli, daemon and tui, with each file list uploaded as the `pack-lists` artifact. The job only records the lists; reviewers read them. |
| `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. |
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
paths, network addresses, email addresses. It never fails because of a finding. To see the
matched text locally (credentials show only their first characters), run
`node scripts/portability-report.mjs`. By default it compares the
merge-base with `origin/main` against `HEAD`; pass `--staged` to check staged changes instead.
The UI suite is a CI matrix leg even though it is advisory locally. Check your PR's status to
see whether a red UI leg is treated as blocking. Branch-protection settings are not in the
repository.
## The local gate: `npm run gate`
`scripts/gate-lane.mjs` runs two legs: `npm run lint` and `npm run test`. It does not run
`test:ui`, the scenarios or the packaging checks. On top of those legs it:
- holds a machine-wide lock on localhost port 40404 (override with `OPENRIG_GATE_LANE_PORT`).
A second gate exits 2 straight away instead of waiting.
- refuses a dirty worktree. Refuses a `node_modules` that is a symlink, or `@openrig/*`
workspace links that resolve outside this worktree. Both are common with shared git worktrees.
- deletes the stale vendored daemon bundle at `packages/cli/daemon/` before the legs run.
- writes `gate-lane-verdict.json` at the repo root, or at `OPENRIG_GATE_VERDICT`, which must
point inside the current worktree (a path outside it exits 3 before the legs run). The file holds the `HEAD` SHA, per-leg results and durations, and a note of other
`node`/`vitest`/`tsc` processes running at the time. It fails if `HEAD` changes during the
run. The file is not gitignored, so don't commit it.
Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
`OPENRIG_GATE_LANE_SMOKE=1` skips the legs to test the wiring only, and the verdict records
`smoke: true`.
## Stub-agent scenarios
### What's in the box
| 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`. |
| Image | `docker/testbed/Dockerfile`, built by `scripts/build-testbed-image.sh`; `docker/testbed/Dockerfile.scenarios` is layered on top by `scripts/run-pr-scenarios.sh` |
### Host mode: while you're writing a scenario
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
`DOCKER_CONTEXT` is set. The engine must be Linux on amd64 or arm64. The script reads the
*server's* platform, so a macOS/arm64 laptop driving a Linux/amd64 engine works.
- Without `--remote`, the script refuses to run anywhere except GitHub Actions on Linux.
- The source build and pack run on your machine. The image build and the containers run on
the engine. Building the image needs network access for the pinned base image, Node and
dependencies. The scenario containers have no network.
- The image is built from your working tree but tagged with `git rev-parse HEAD`. Commit
first, so the tag names what you actually tested.
- It never pushes images. It removes only the containers it named. If the SSH connection
drops, it prints the name of any container it could not remove.
What happens, in order:
1. `scripts/build-testbed-image.sh` packs `@openrig/cli` from your tree. It installs the
package into a digest-pinned Debian image with Node, checks `rig --version`, and checks
that the installed daemon loads. The result is tagged `openrig-testbed:<sha>`.
2. `docker/testbed/Dockerfile.scenarios` adds a bundled copy of
`packages/test-system/ci/run.mjs` plus the fixture and library scenarios.
3. Each run uses a fresh container: `--network none`, read-only root plus a 512 MB `/tmp`
tmpfs, `--cap-drop ALL`, `no-new-privileges`, non-root, 2 CPUs, 2 GiB, 256 PIDs and a
300-second deadline.
4. In lost-baton mode, the runner stops the daemon, changes exactly one queue row in the
scratch database from `in-progress` to `pending`, and restarts the daemon.
`packages/test-system/ci/result.mjs` then accepts only one outcome: a failure at the
post-restart queue assertion, with `baton-1` observed as `pending` at the expected
destination. A startup error, a timeout or any other failure does not count as a caught
regression. Healthy runs must pass with no fault armed.
5. The `transcript` and `capture` runs are passing form only: `result.mjs` accepts only a
clean PASS of the named scenario, with no seed or fault. Their planted failures are
product-code mutations, so they stay per-PR evidence and are not CI runs. Stub scripts
work here because the runner executes the pipeline in host mode inside the image.
A single healthy run proves that one leg only. The paired fault run is what shows the
assertion can catch the failure.
Adding a new case to this pack today means changing three files:
`scripts/run-pr-scenarios.sh` (the `--case` list), `CASES` in
`packages/test-system/ci/result.mjs`, and the fault controller in
`packages/test-system/ci/run.mjs`, which is currently specific to the baton row. A
passing-only case needs no fault controller: add it to `PASSING_CASES` and the `--case` list
in `scripts/run-pr-scenarios.sh`, and to `PASSING_CASES` in `result.mjs`.
### What a green stub scenario proves
- The packaged CLI installs into a clean Linux container and starts a daemon.
- `rig up` and `rig down` of a stub topology go through the real daemon, tmux, SQLite and CLI.
- Queue state, read through the shipped `rig queue list` path, survives a daemon restart.
- With the paired seeded run: the assertion fails when that state is broken.
### What a stub can and cannot prove
The stub does not fabricate product outputs. It runs the real daemon, tmux and CLI. But it is
not Claude Code or Codex, and it differs from them in ways that decide what a stub green
means:
| Area | What the stub does at `e8f0ab34` | What that means for your test |
|---|---|---|
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane" (with transport.verify_input_box on, a Claude Code or Codex send means "the seat was idle before the paste and its input box took the exact text"), not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
| Launch path | `StubRuntimeAdapter` types `node <stub-runner> …` into the pane (`tmux.sendText`, then Enter). Claude Code with an explicit permission mode launches through a managed launch (`ClaudeManagedLaunch.prepare`, `tmux.sendShellCommand`). Otherwise Claude Code, Codex and Pi launch through `SeatLaunchEnvironment.command` and `tmux.sendShellCommand`; the stub adapter does not get that environment. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal` and `claude-code`, and only `codex` has a native-process proof; Claude Code ordinary delivery applies its own uncertainty policy at the input boundary. | A stub seat doesn't exercise the seat launch environment, managed launch, wrappers or native-process identity (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub carries the resolved `floor` / `full_bypass` posture, including one set by a declarative `permission_policy`, into its runner's `--posture` argument and READY line. It ignores non-interruptive launches, and it doesn't exercise Claude or Codex native permission behaviour. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
| 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). |
| 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. |
The most useful next pieces of harness work, roughly in dependency order:
1. A stub that consumes input and answers a fresh nonce.
2. A model-free test entry point shaped like a provider, that goes through the real native
adapter and managed launch. Package tests such as `claude-interactive-launch.test.ts` and
the `*-native.test.ts` files already drive the real adapters through tmux against an
offline recorder; they prove command resolution and arguments.
`claude-executable-foreground.test.ts` holds that recorder open in private Bash, zsh, dash and ksh panes
to check parent PID, process-group ownership, tmux's pane command and parent-shell
restoration for unmanaged fresh, fork and resume launches. These fixtures do not prove
native readiness, history or answers.
3. Lifecycle and identity steps (tmux reset, archive/remove).
4. Then pickup, wake and restore scenarios.
### 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:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
| `queue-baton-survives-restart` | Runs in CI: healthy / seeded `baton-drop` / healthy | Extend it to handoff, block, resolve and unclaim |
| `clean-lifecycle-no-residue` | The post-`down` "nothing left over" assertion is commented out, because the format has no way to say "empty" | Add a real absence assertion |
| `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 |
| `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
historical markers, not executable fault injections.
### Ordinary CLI journeys with stub seats
`packages/daemon/test/stub-cli-journeys.integration.test.ts` reuses the scenario
helpers with one private daemon and two stub rigs. It checks three contracts
through the real CLI: verified send appears in the addressed pane but not its
sibling; current-rig and explicit rig listings contain exactly the expected seats;
and a filtered stream item survives daemon restart and an idempotent emit retry.
After building, run the focused file with:
```sh
npm test -w @openrig/daemon -- test/stub-cli-journeys.integration.test.ts
```
The file also runs in the daemon package-test job. It covers these command
contracts without extending the YAML grammar. The corresponding library scenarios
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.
## 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
diff can affect one, your PR description should do one of these:
- link the scenario that covers it, and say which layer you ran it in;
- add or extend a scenario. For a regression scenario, show that the fixed code passes and
that the planted old defect fails *at the intended assertion*. A setup crash or a timeout
doesn't count;
- or say precisely why the stub can't exercise it. Use the table above, which covers
message consumption, native launch and permissions, tmux reset and so on. Then say what
you ran instead, for example a manual run with the real runtime.
This is part of normal review, not an extra approval step. It also isn't a claim that
changes outside the arteries map are safe.
## Live-model evals
`packages/test-system/evals/` is a separate check from scenarios. A real seat receives a
natural prompt and decides what to do. A case passes when the seat's transcript shows the
expected `rig context get <entry>` command, in the expected order. Cases live in
`cases/selection.yaml` (10) and `cases/loading.yaml` (4).
```sh
# Harness check: a fake provider fed a JSON map of prompt -> transcript.
# Any prompt without a transcript counts as an error, never as a pass.
npm run eval -w packages/daemon -- --transcripts <transcripts.json> --out <result.json>
- **Live runs use provider credits, and the results vary from run to run.** CI never runs them.
- `--provider rig` needs `OPENRIG_HOME` set, and it supports Claude Code seats only. It
refuses a Codex seat.
- `--seat-spec` runs `rig up` of a scratch rig against **your current daemon**, then
`rig down` at the end. `--seat` attaches to an existing seat and leaves it running.
- `evals/rig-seat/example-scratch-rig.yaml` sets `permission_policy: builtin:yolo` and
`cwd: "."`. It also references `local:agents/blank`, and no such agent directory ships
beside it. Read the file and expect to adapt it before you use it.
- Before any run, the runner builds the production context package in a temp directory and
refuses if any case's entry doesn't resolve in it.
- `npm test` covers the harness without a model: the case schema, the grader, the fixtures
and production entry resolution (`packages/daemon/test/eval-*.test.ts`).
## Docker testbed runbooks
`docker/testbed/runbooks/` holds manual procedures, L0 to L6, that you run on a machine with
Docker. They check the testbed image itself:
- L0: resolve the pinned base image and the stub asset list
- L1: PTY allocation
- L2: tmux server lifecycle
- L3: the daemon inside a container, settling a stub rig
- L4: the scenario environment still refuses a foreign daemon target inside a container
- L5: several containers acting as named hosts on one Docker network
- L6: the scenario runner driving a scenario through a container
Use them when you change `docker/testbed/`, `scripts/build-testbed-image.sh` or the
container staging helpers. Ordinary PRs don't need them. They were written before the CI
scenario job and contain internal planning references, so treat them as procedures to adapt,
not as current status.
## Help wanted: command families without a behavioural scenario
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
> top-level families. At `e8f0ab34`, `packages/cli/src/index.ts` registers 87 top-level
> commands; `roster` and `telemetry` were added since and have no row yet. Before picking one up, check `packages/test-system/scenarios/`, the cases in
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
> check only proves the command is discoverable. Existing unit tests are not counted here.
> They exist, but this list is about behavioural scenarios.
Status today: `queue` has partial coverage, namely create, claim and list, plus the claim
surviving a restart. `down` has partial coverage in
`packages/test-system/scenarios/down-stops-every-seat.yaml`: after `rig down`, `rig ps` reports
every seat stopped and the rig still listed. `send` has partial coverage in
`send-renders-in-addressed-pane.yaml` beside it: the envelope and the message body render in
the addressed seat's pane. `transcript` has partial coverage in
`transcript-reads-addressed-seat.yaml`: each of two seats' scripted output is readable through
that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage in
`capture-returns-addressed-seat.yaml`: in a rig whose member names overlap by prefix (`impl`,
`impl2`; `dev-impl`, `dev-impl2` with the pod), `rig capture <seat>` returns each seat's own
pane. The scenario catches a capture that resolves the seat by a prefix match on its member
name (so `dev-impl` also matches `dev-impl2`) and takes the first hit, or any resolver that
returns the same pane for both seats; it does not exercise a prefix match on the full seat
name. All six run with the local runner (`run-scenarios.mjs`). The transcript, capture and daemon
scenarios also run in the CI job, in passing form only; the down, send and up scenarios don't.
They do not prove that panes are gone or that a seat consumed a message. The transcript
case does not establish ordering, uniqueness, sibling-token absence, tail limits or restart
persistence. `up` has partial coverage in `up-relaunch-restores-a-two-seat-team.yaml`: a
two-seat stub team comes up, goes down, and the same spec starts a new team in place of the
stopped one with the same name, exactly two seats, both running, not archived, so a
relaunch that re-records the old seats into the team's row fails on the final `ps` counts.
It does not prove that conversations survive the round trip (that is the `--existing`
restore path), that a seat consumed a message, or that the panes are gone after `down`.
`daemon` has partial coverage in `daemon-restart-preserves-team-identity.yaml`: a two-seat
stub team is up, the runner restarts the scratch daemon, and `rig ps --json` must report the
same name still running with exactly two seats, both running, not archived, so a restart
that loses the team's row, or changes its seat counts or status, fails on the post-restart `ps`;
`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.
Before you start, two honest constraints:
- Today's step verbs can't run arbitrary `rig` commands. Many rows below need a runner
binding first. One proposal (not implemented) is a single structured-argv step that runs
the installed CLI through the existing `rigBin`, instead of one new verb per command.
- Rows that need a seat to *answer* (anything with "nonce") depend on the input-consuming
stub described above.
- `expect` has no negative, absence or count form. `match` is a structural subset and
`contains` is a substring, so "the sibling received nothing", "the panes are gone" or "no
duplicates" can't be asserted yet. An exact field value, such as `runningCount: 0` on the
`ps` surface, is the closest available form.
- The `restart` step runs `rig launch <rig> <node> --json` and passes no other flag; the node
is the logical ID (for example `dev.qa`). A stub seat has no resume token. `rig launch` has
no `--fresh` option: a deliberate fresh start of a stub seat after `down` goes through
`rig up --existing <rig> --fresh <seat>` or `rig seat launch <seat> --fresh --reason "<why>"`, so the
`launch` row needs a runner binding to one of those first.
| Group | Family | Proposed first check (observable result) |
|---|---|---|
| lifecycle | `start` | Restore two stopped stub seats. Both answer distinct fresh nonces, and no second daemon starts. |
| lifecycle | `daemon` (partial) | Restart-preserving-the-persisted-team-row is covered by `daemon-restart-preserves-team-identity.yaml`. Next: port and process agreement through public commands, and `stop`, which needs a runner binding. |
| lifecycle | `bootstrap` | Bootstrap a minimal stub spec. Seats come up ready and the selected spec is the one used. |
| lifecycle | `up` (partial) | Relaunch-not-duplicating-identities is covered by `up-relaunch-restores-a-two-seat-team.yaml`. Next: each seat consumes its own send, which needs the input-consuming stub. |
| lifecycle | `down` | (partial) Next: its panes are gone, a sibling rig still answers, retained data follows the docs. |
| lifecycle | `create` | Create a one-seat rig through the public path. It appears and answers without a hand-written topology. |
| lifecycle | `launch` | Fresh, relaunch, resume and fork on provider-shaped fixtures. Correct restored token, one live process. |
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
| continuity | `agent-image` | create/show/pin/unpin/delete for a provider-shaped fixture. References and identity match. |
| continuity | `fork` | Fork at a known history token. The child keeps the prefix with its own identity, and the parent still answers. |
| continuity | `restore-check` | One restorable and one missing transcript. Readiness tells them apart without relaunching. |
| continuity | `compact-plan` | Scripted history and usage fixtures. Candidates and exclusions match thresholds, and nothing is compacted. |
| continuity | `compact` | Input-driven compact and restore keep the role and token. Messages before and after reach the same seat. |
| continuity | `handover` | Scripted predecessor and successor. Context is delivered before the rebind, and the successor answers. |
| recovery | `crash-cart` | Stop only the scratch daemon. The output names paths and state, and the command changes nothing. |
| recovery | `preflight` | Ready and missing-dependency fixtures produce named checks without launching seats. |
| recovery | `doctor` | Healthy and damaged scratch installs. Exact diagnosis, no automatic repair. |
| recovery | `destroy` | In a disposable scenario HOME only. Removes the intended state and keeps explicitly retained markers. |
| registry | `gateway` | Add, show, update and remove a synthetic human. Reads match exact fields, and removal sticks. |
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
| coordination | `workflow` | validate/compile/instantiate a tiny flow. It transitions and resumes after a restart with no duplicate queue work. |
| coordination | `chatroom` | send/history/topic/clear in a scratch room. A bounded watcher sees exactly the ordered messages. |
| coordination | `heartbeat` | Known execution-proof and missing-closure fixtures report the right status. Empty output isn't completion. |
| authoring | `export` | Export a stub rig, then validate and re-import it. Membership and configuration are equivalent. |
| authoring | `import` | Import a minimal spec, and the export matches it. Malformed input leaves state unchanged. |
| authoring | `package` | validate/plan/install/rollback in scratch. Installed files and prior state match. |
| authoring | `requirements` | Complete and missing capability fixtures produce the right named requirements. |
| authoring | `bundle` | create/inspect/install a local bundle and round-trip it. A missing asset gives a named failure. |
| authoring | `agent` | Validate a good and a malformed stub AgentSpec. The diagnostic names the bad field. |
| authoring | `spec` | validate/audit/preflight/show a minimal spec, with predictable diagnostics for a malformed field. |
| authoring | `specs` | add/show/preview/rename/remove a library fixture. A missing reference fails explicitly. |
| authoring | `workspace` | validate/doctor known roots plus one missing-frontmatter case. Reports the exact affected path. |
| authoring | `config` | set/get/reset a scratch key and init a workspace. Readback and files match the requested root. |
| presentation | `ui` | Open the URL through a fake browser executable and read a local UI route. Rendering is out of scope. |
| presentation | `tui` | Drive the TUI command bar and socket over two stub rigs. select, mutate and back show exact identities. |
| presentation | `terminal` | open/status/views against a fake terminal-app endpoint. Correct target and layout arguments. |
| adoption | `discover` | One unmanaged and one owned scratch tmux session. Only the intended candidate is discovered. |
| adoption | `attach` | Attach to the intended seat. `whoami` reports it without duplicating the process. |
| adoption | `bind` | Bind one discovered pane. A different, already-occupied identity is refused. |
| adoption | `adopt` | Adopt two scratch sessions. PIDs and history are preserved, and the identities become addressable. |
| adoption | `reconcile-session` | Rebind a hand-resumed provider-shaped fixture without starting or typing into another process. |
| adoption | `unclaim` | Release an adopted seat. The tmux process and its history stay alive. |
| adoption | `release` | Release one rig's claimed sessions. Processes survive, and a sibling rig is unchanged. |
| identity | `archive` | Archive an old same-name generation. The current one stays routable, and the old data is kept. |
| identity | `unarchive` | Unarchive a retained generation without taking over another live seat. |
| identity | `remove` | Remove an archived generation or one member. Only that identity goes, and a reused name still routes. |
| identity | `whoami` | Inside each stub seat, it returns that seat, not a sibling or the caller. |
| identity | `seat` | Status, held-message guard, stop/launch and resume token keep the right process and permissions. |
| integration | `mcp` | Serve against a scratch daemon. One listed tool's value matches CLI state. |
| integration | `slack` | Manifest and config round trip, plus deterministic inbound and outbound fixture requests. No real Slack account. |
| integration | `ask` | Search fixture transcripts via a fake model endpoint. Citations resolve to the stored lines. |
| integration | `file` | Copy a fixture payload through isolated transport endpoints. Verify bytes and paths. |
| integration | `env` | A fixture service-backed rig. status/logs/down reflect that service and stop only it. |
| delivery | `transcript` (partial) | Own scripted output from two addressed seats is covered by `transcript-reads-addressed-seat.yaml`. Remaining: ordering, uniqueness, no other seat's token, tail limits and restart persistence; absence/count assertions need a runner binding. |
| delivery | `send` | (partial) Next: send a fresh nonce and require an answer derived from it. Boot text or echo can't pass. |
| delivery | `capture` | (partial) Each seat's own token, under prefix-overlapping member names. Next: the not-found, tail (`--lines`), sibling-absence and `--rig`/`--pod` cases, which need an error form, a tail option, an absence form and a multi-target surface in the runner. |
| delivery | `broadcast` | Broadcast a nonce to a rig or pod. Each intended seat answers once, and an excluded seat gets nothing. |
| delivery | `walk` | Walk two pieces through an input-consuming stub. Order is kept, and a bad piece isn't reported as delivered. |
| context | `context` | add/get/compose/trace/profile over a fixture project. Output bytes, order and roots match. |
| context | `plugin` | list/show/used-by/validate a fixture plugin. A missing resource is not the same as a valid empty selection. |
| context | `skill` | Resolve and audit a fixture skill, then inspect the projection the consumer actually gets. |
| context | `mode` | set/effective/cite/unset at nested scopes. Precedence holds, and the inherited value returns after unset. |
| context | `startup-proof` | Submit a proof from a real stub identity. Check the stored attribution and the duplicate behaviour. |
| permissions | `policy` | Apply and read an explicit permission selection using a provider-shaped fixture. It survives a restore, and the default is unchanged. |
| accounts | `auth` | Synthetic auth files in scratch: save, list, validate, switch. No provider contact, no real credentials. |
| accounts | `provider` | Synthetic account, binding and signal state. The precheck refuses where it should, and a switch keeps seat history. |
| topology | `expand` | Add a pod to a live stub rig. Old seats survive, and new members answer nonces. |
| topology | `add` | Add one seat. Exact binding, siblings preserved, and the duplicate-name behaviour is explicit. |
| topology | `grow` | Grow two seats. Identities are unique, config is inherited, and existing history is kept. |
| topology | `shrink` | Remove a pod. Unrelated seats still answer, and the removed membership is gone. |
| onboarding | `setup` | Scripted newcomer answers against synthetic providers. No install or login for an unused provider. |
| work | `scope` | Create a mission and a slice. move/progress/notes/graph readback keep identity and paths. |
| work | `proof` | add/judge/show evidence in a fixture scope. Readiness updates without overwriting earlier proof. |
How to make a case useful:
- Assert actual state or content, not just exit code 0. Where ordinary use includes bad
input, add an invalid or missing input case too.
- Reuse the existing runner and the installed CLI. Don't add a second scenario engine.
- If a scenario exposes an existing product bug, keep the failing case and open an issue.
Include the exact source revision, the shortest failing sequence, and the intended versus
observed result. Don't weaken the assertion to make it pass. Keep "the harness setup
failed" separate from "the product misbehaved".
- A check that only covers the success path is still useful. Just don't call it a
regression detector until you have seen its negative case fail.
## Existing docs in this area that are out of date
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.
- `packages/test-system/scripts/README.md` and `packages/test-system/ROUTING.md` are
dependency-routing notes from when the scenarios were first written. Several of the items
they list have since landed. Treat them as history.
- `docker/testbed/runbooks/`: see above. They are manual procedures that predate the CI job.
| `/markdown` | **NOT a route** — `MarkdownViewer` is a component used inside `MissionScopePage` | (no route) | grep `routes.tsx`@HEAD: zero `path: "/markdown"` |
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources and the shipped agent specs since then checked)
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/agent-resolver.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/skill-catalog.ts`
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
@@ -20,7 +20,7 @@ profiles:
skills: []
guidance: []
subagents: []
hooks: []
plugins: []
runtime_resources: []
resources: {}
@@ -35,7 +35,7 @@ startup:
```yaml
name: implementer
version: "1.0"
description: Implementation agent — writes code following TDD discipline
description: Implementation agent — implements and verifies coherent changes
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
| `version` | string | yes | — | Spec version. Quote it (`"1.0"`): an unquoted number fails as a non-string. An importing spec that pins `imports[].version` must match it exactly. |
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, plugins, runtime resources). These are the available pool that profiles select from via `uses`. |
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
---
@@ -163,6 +165,7 @@ startup:
defaults:
runtime: claude-code
model: claude-opus-4-6
effort: high
lifecycle:
execution_mode: interactive_resident
compaction_strategy: default-compaction
@@ -171,8 +174,9 @@ defaults:
| Field | Type | Required | Default | Description |
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
| `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. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
@@ -180,8 +184,9 @@ defaults:
| Field | Type | Default | Allowed Values |
|-------|------|---------|----------------|
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
| `compaction_strategy` | string | `default-compaction` | `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover`; deprecated aliases accepted with a validation advisory: `harness_native` → `default-compaction`, `pod_continuity` → `handover` (`custom_prompt` is explicitly rejected in v1) |
| `compaction_strategy` | string | `default-compaction` | `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover`; deprecated aliases accepted with a validation advisory: `harness_native` → `default-compaction`, `pod_continuity` → `handover` (`custom_prompt` is explicitly rejected in v1). `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) |
| `mechanic` | string | — | A canonical `seat@rig` session address; needed by `apprentice-handover` |
---
@@ -197,15 +202,18 @@ imports:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). When set, the imported spec's `version` must equal it, or resolution fails with `version_mismatch`. |
### Import Resolution
- `local:` paths resolve relative to the importing spec's directory
- `path:` paths are absolute filesystem paths
- Imported resources become available for `uses` references in profiles
- When referencing imported resources in `uses`, use qualified`namespace:id` format (e.g., `shared:openrig-user`)
- Unqualified references (just `id`) resolve against the spec's own local resources first
- A qualified reference`namespace:id` (e.g., `shared:openrig-core`) names the imported spec by its `name` field
- An unqualified reference (just `id`) resolves against the spec's own resources first, then against the one import that
declares it. If two imports declare it, the reference is ambiguous and fails: qualify it
- An imported spec may not have imports of its own, and its `name` may not contain `:`. Two imports that resolve to the
same spec name are refused, and import cycles are detected
### The Shared Import Pattern
@@ -216,7 +224,13 @@ imports:
- ref: local:../../shared
```
This gives access to the full pool of shared skills (openrig-user, systematic-debugging, development-team, etc.) and agents select the ones they need via profile `uses`.
This gives access to the shared pool: skills such as `systematic-debugging`, `verification-before-completion`,
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
`refocus-channel.md`); a seat that didn't select it gets neither.
| `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). |
### Uses
@@ -258,18 +273,28 @@ The `uses` block selects which resources from the `resources` pool (including im
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
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
`<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
same ID. When a profile selects a skill from inside the spec's own folder and the managed catalog holds a different
copy, the spec's copy is used and launch warns `skill_bundle_precedence`. Any other differing copy fails launch with
`skill_identity_conflict`.
---
@@ -286,10 +311,12 @@ resources:
subagents:
- id: helper
path: subagents/helper
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
plugins:
- id: vault-tools
source:
kind: local
path: plugins/vault-tools
plugin_type: auto
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
@@ -297,26 +324,65 @@ resources:
type: claude_settings_fragment
```
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`.
The only declared resources delivered are those that the active profile's `uses` block references. Two sources add
skills outside `uses`: the managed skill catalog's system selection and a project's `install.skills`.
`resources.hooks` is refused at validation: hooks now ship inside plugins, declared under `resources.plugins`.
### Plugin paths
A local plugin's `source.path` supports these forms:
| Form | Resolution |
|------|------------|
| `/absolute/plugin` | The exact absolute path. |
| `~/plugins/example` or `~` | The operating-system user home. |
| `openrig-home:plugins/openrig-core` | Relative to the daemon's configured OpenRig home, including a non-default `OPENRIG_HOME`. |
| `plugins/example` or `./plugins/example` | Relative to the agent spec directory. |
For a plugin seeded by OpenRig, use the home-relative form so the spec selects
that daemon's copy:
```yaml
resources:
plugins:
- id: openrig-core
source:
kind: local
path: "openrig-home:plugins/openrig-core"
profiles:
default:
uses:
plugins: [openrig-core]
```
`openrig-home:` is resolved by the daemon from its configured state root. It is
not shell interpolation and does not consult a seat's shell startup files. Its
suffix must be relative. Existing absolute, tilde and spec-relative paths keep
their meaning; `~user` is still a literal relative segment, not a user lookup.
This form needs OpenRig 0.6.5 or later; keep bundles using it pinned to such a
version.
### Resource Categories
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
| `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. |
| `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. |
| `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.
Recognized runtime resource types:
- `claude_settings_fragment` — merge a JSON object into `<cwd>/.claude/settings.local.json`.
- `claude_mcp_fragment` — merge a JSON object into `<cwd>/.mcp.json`.
- `codex_config_fragment` — upsert a TOML fragment into `~/.codex/config.toml` inside an OpenRig-managed block.
- `claude_activity_hooks` — reconcile OpenRig's activity hooks into `<cwd>/.claude/settings.local.json`.
- `codex_config_fragment` — upsert a TOML fragment into `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) inside an OpenRig-managed block.
Unknown runtime resource types are still copied to the runtime extension directory for agent-visible context.
Unknown runtime resource types are still copied to the Claude or Codex runtime extension directory for agent-visible context.
#### Writing a `codex_config_fragment`
@@ -353,7 +419,8 @@ overwritten, and the rest of the fragment still applies.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
`source.path`, which takes the forms under "Plugin paths"
- Paths resolve relative to the agent spec's directory
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
@@ -364,16 +431,15 @@ overwritten, and the rest of the fragment still applies.
guidance:
- id: role
path: guidance/role.md
target: CLAUDE.md # optional — where to merge
merge: managed_block # optional — how to merge (default: managed_block)
merge: managed_block # optional — the only strategy applied
```
| Field | Type | Required | Default | Description |
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`. |
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
---
@@ -393,7 +459,7 @@ restore behavior, and the distinction between readiness and verified proof.
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Before harness boot | System chooses based on file type |
| `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` |
| `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 |
@@ -407,17 +473,26 @@ restore behavior, and the distinction between readiness and verified proof.
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
4. Import `version` must be an exact version (no ranges).
5. `profiles` must be a map (object), not an array.
6. Profile `uses` references must resolve to declared resources (local or imported).
6. Profile `uses` references other than skills must resolve to declared resources (local or imported). Skill
references are checked at launch, not at validation (see "Uses").
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
8. Qualified `uses` references must be in `namespace:id` format.
9. All resource paths must be safe relative paths.
10. Resource IDs must be unique within their category.
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
11. `runtime_resources` entries require `runtime` and `type` fields.
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
a plugin instead.
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
`plugin_type` of `claude`, `codex` or `auto` when given.
12c. Lifecycle `mechanic` must be a canonical `seat@rig` session address.
12. Lifecycle `execution_mode` must be `interactive_resident`.
13. Lifecycle `compaction_strategy` must be one of `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover` — or a deprecated alias (`harness_native`, `pod_continuity`), which validates with a deprecation advisory and normalizes to its canonical value (OPR.0.5.6.20).
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
15. Startup files and actions follow the same validation rules as in RigSpec.
Unknown keys are ignored, not refused. `rig agent validate <path> [--json]` runs these syntax checks through the running
daemon; it doesn't resolve imports, find skills or check that files exist, so a missing skill shows up only at launch.
---
## File System Layout
@@ -434,8 +509,8 @@ my-agent/
skills/
my-skill/
SKILL.md # skill content
hooks/
pre-commit.sh # hook script
plugins/
my-plugin/ # plugin (skills, hooks) with .claude-plugin/ and/or .codex-plugin/
| `pm` | `product-management/pm/` | claude-code | office-hours, context-builder, requirements-writer, ui-mockup, plan-review, exec-summary, backlog-capture; `advisor` profile: orchestration-team | Product manager; its `advisor` profile is the factory's advisor |
| `analyst`, `synthesizer` | `research/analyst/`, `research/synthesizer/` | claude-code, codex | — | Research team |
| `conveyor-lead`, `-planner`, `-builder`, `-reviewer` | `conveyor/` | claude-code, codex, claude-code, codex | per role | Kept for the `factory-rsi` world bundle (its planner); no built-in team uses them |
| `factory-rsi-release-manager`, `factory-rsi-dogfood` | `factory-rsi/` | claude-code | per role | Used by the `factory-rsi` world bundle (not a built-in team) |
Last validated: 2026-10-05, against main `9b88b118`
Applies to: OpenRig 0.6.8
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
---
## Continue after a native consent prompt
If a fresh Claude seat is waiting at its bypass-permissions warning, review and
answer that warning in the seat's native pane. Unless the rig was launched with
`--non-interruptive` (see [non-interruptive mode](non-interruptive-mode.md)),
OpenRig does not accept it for you, and it never sends configured startup context
into the active dialog. The seat reports `attention_required` and retains that
context for the same occupant.
After accepting, run `rig seat continue <seat>` (or press `c` on the seat in the
TUI, shown while its context is pending). This delivers the pending context
without relaunching Claude, and its first prompt reminds the seat that this is a
fresh conversation and how to find its work. It does not replay a delivery that
already started. A timeout, or a refusal because nothing is pending, reports an
unknown outcome; inspect `rig seat status <seat>` before taking another action.
It is also refused while the prompt is still showing (`attention_required`), or
when the seat's binding or runtime has changed since launch.
Runtime readiness, startup delivery, and orientation proof remain separate;
a missing proof does not block this continuation.
Other fresh-launch prerequisites, such as login or workspace trust, use the same
continuation: resolve the prerequisite in the native pane, then run the displayed
command. `rig ps --nodes` shows it under "Startup details (<seat>)", and
`rig up` and `rig bundle install` print it as "Startup attention (<seat>)":
"After resolving it in <session>, run: rig seat continue <session>".
The attention codes that stop startup this way are `trust_gate`,
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
**The recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
---
@@ -118,30 +173,35 @@ Startup content is merged additively through layers. Each layer adds to what the
```
1. Agent layer — from the AgentSpec's top-level startup block
2. Profile layer — from the active profile's startup block
3. Rig layer — from the RigSpec's top-level startup block
4. Culture layer — from the RigSpec's culture_file
3. Culture layer — OpenRig's default culture, then the RigSpec's culture_file
4. Rig layer — from the RigSpec's top-level startup block
5. Pod layer — from the pod's startup block
6. Member layer — from the member's startup block in the RigSpec
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
After the files, OpenRig sends a session-identity message (rig, pod, member, logical ID) once the seat is ready.
### Practical Guidance
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**Most rigs only need three layers:** agent (role.md), culture, and the built-in operator layer. Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
**The rig's own culture file is high-value and often skipped.** Every seat gets OpenRig's default culture, but that says nothing about how this team communicates and coordinates. Write a `CULTURE.md`. Even a short one dramatically improves team coherence.
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
@@ -153,8 +213,8 @@ Startup content is merged additively through layers. Each layer adds to what the
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managedblock | Role guidance, culture, project context |
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -167,11 +227,129 @@ Files delivered via `send_text` happen AFTER the harness is ready. The agent rec
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
- Context that should feel like an operator briefing, not pre-loaded content
On a fresh launch the first message is one turn: the session identity, the first `send_text` file and, when a startup
proof is selected, its challenge (below).
A file's `required` field defaults to `true`: a required file that can't be delivered fails startup, and an optional
one is dropped without a message.
### Readiness, startup proof and failure
**Readiness.** After launch OpenRig polls the pane until the harness is interactive (backing off 1, 2, 4, 8 and 16
seconds), for up to 30 seconds by default. Change the window with
`rig config set runtime.readiness_timeout_seconds <1-600>` (or `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`); it
applies to the next launch. A recognized prompt (the attention codes above) ends the wait at once as
`attention_required`. A timeout ends startup as `failed` ("Readiness timeout after Ns — harness did not become
interactive"), and the post-launch files aren't sent. A Codex fork that the rig launches (a member with
`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.
**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
staged in <session>; press Enter in that pane." The seat can still end `ready` with its submission `unverified` or
`staged`, so `ready` doesn't prove the prompt was submitted. After all files and actions are delivered, readiness is
checked once more, so a prompt that appears by then gives `attention_required`.
**Startup proof.** A `startup_proof` action selects `authenticated` or `none` (the default is none; the last applicable
one wins, and it needs `idempotent: true`). With `authenticated`, a fresh launch of an agent seat includes a challenge,
and the seat answers it with `rig startup-proof submit --challenge-id <id> --answer <answer>`. `rig ps --nodes --full`
shows the result in the ORIENTED column: `verified`, `missing`, `rejected` or `n-a`. A missing proof doesn't block startup.
**After a failure.** Delivery, launch and action failures, and readiness timeouts other than a Codex fork's, give
`failed`. Every 30 seconds the context monitor marks a `failed` or `attention_required` seat `ready` once its pane reads
ready, unless its fresh or Codex fork context is still pending for `rig seat continue`. A seat that timed out can therefore read `ready` without having received its
post-launch files. If it needs them, first look at what that occupant has done; then
`rig seat launch <seat> --fresh --reason <text>` starts a blank conversation for the seat. If the occupant is still
live, the command refuses with `session_live` unless you also pass `--stop`, which replaces that occupant deliberately.
**Preview before replacing a seat.** `rig seat launch <seat> --fresh --plan` inspects the selected
seat without starting tmux, stopping or launching a process, projecting startup files, or changing
lifecycle/queue records. Add `--json` for structured output. `--fresh` is still required;
`--reason` is optional for the preview and required when executing. Pass `--stop` to preview the
intended replacement of a live managed occupant. Adopted and unmanaged occupants remain blocked.
The plan identifies the target, current occupant, stop requirements, blockers, startup counts,
and each sibling seat's unchanged effect. Siblings stay attached and untouched. Launch composes
a new command through the runtime adapter using current settings; it does not replay a stored
wrapper command. The seat's launch environment and managed executable selection still apply.
The plan does not construct or display the launch command.
A preview is a snapshot, not a reservation or a native-readiness check. Unreadable tmux produces
`complete: false` and `launchable: null` (unknown), unless a known blocker already proves
`launchable: false`. A successful preview exits 0, including blocked or incomplete plans; scripts
must inspect `launchable === true` before deciding to execute. Request failures still exit nonzero.
Execution always resolves and checks the current state again. An older daemon without the preview
endpoint refuses the request; the CLI never falls back to executing a launch.
### Change an existing seat's working directory
Editing `cwd` in an already applied RigSpec doesn't update an existing seat's saved launch directory. Use `set-cwd` to
**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:
- **Collector provisioning** writes `.openrig/context-collector.cjs` and merges `.claude/settings.local.json` in the
saved directory, under its existing preservation rules.
- **Managed compaction,** manual (`POST /api/compaction/trigger`) or automatic, asks the seat to write its restore map
under it. That may be outside the seat's current workspace, so a seat without bypass permissions may meet a write
prompt.
Adopting promptly is guidance. It doesn't protect against a poll or a compaction that runs before your next command.
**Before adopting,** don't `rig down`/`rig up` or hand over by fork. Those resume the existing conversation in the
saved directory, and Claude keeps conversations per project directory, so a resume there may not find it.
Cross-directory resume hasn't been run natively; this is the order to follow. `rig seat restart --keep-conversation`
(Codex) isn't adoption: it respawns in the directory it observes from the running process.
**Failures:** a refusal, missing or malformed saved startup context, or an audit-write failure leaves the prior
directory saved, and changes no seat, custody or startup file (a refusal may leave the new folder created). A concurrent configuration change is reported, not overwritten. A CLI timeout means the outcome is
unknown; check `rig seat status` before retrying. Choosing the same directory again records no second event.
**The old directory:** files there are left as they are, since other seats may still use them. The source RigSpec
declaration isn't changed either; update it separately if a future recreation should use the new directory.
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered on first launch only
-`restore` — delivered on restore from snapshot
- `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
- 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.
@@ -184,15 +362,10 @@ The AgentSpec and RigSpec allow declaring deterministic environment configuratio
### What the Spec Supports
**Hooks** (in `resources.hooks`):
```yaml
resources:
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
```
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
**Hooks** ship inside plugins: declare the plugin under `resources.plugins[]`. AgentSpec validation refuses
`resources.hooks`. OpenRig loads one plugin's hooks into a seat itself: the shipped `openrig-core` plugin, for a Claude seat
that selects it (see [the refocus channel](refocus-channel.md#the-mechanism)). The folder a declared plugin is projected into
is not one Claude Code or Codex loads, so a plugin of your own does not get its hooks run by that projection.
**Runtime resources** (in `resources.runtime_resources`):
```yaml
@@ -214,23 +387,26 @@ startup:
phase: after_ready
idempotent: true
```
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
Each value is typed into the harness's input and submitted, so it is a prompt or a slash command, not a shell command.
Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof" above); `shell` is refused.
`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`.
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
| Hooks | **Through plugins, limited** | `resources.hooks` is refused. OpenRig loads the shipped `openrig-core` plugin's hooks into a Claude seat that selects it (Claude Code v2.1.280 or later); a plugin you declare under `resources.plugins[]` is projected, but its hooks are not loaded. |
| Runtime resource projection | **Supported for recognized fragments** | `claude_settings_fragment`, `claude_mcp_fragment`, and `codex_config_fragment` are applied to provider config. Unknown types are copied to runtime extension directories. |
| Permission configuration | **Native settings plus managed launch flags** | OpenRig launches Claude with `acceptEdits` and Codex with `workspace-write` unless an explicit supported selection changes them. It does not add a global Claude `Bash(rig:*)` allowance. Use [the first-user permission guide](getting-started.md#opt-in-permissive-operation) for opt-in and custom choices. |
| MCP installation | **Experimental** | Claude Code:`/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
| Permission configuration | **Native settings plus managed launch flags** | OpenRig launches Claude with `acceptEdits` and Codex with `workspace-write` unless an explicit supported selection changes them; a seat with no policy, selection or named Codex profile also gets the per-launch [team default](rig-spec.md#team-launch-defaults), and the `kernel` rig's seats get a wider operational default ([rig spec, "At launch"](rig-spec.md)). Neither is written to a settings file. Use [the first-user permission guide](getting-started.md#opt-in-permissive-operation) for opt-in and custom choices. |
| MCP installation | **Supported for Claude fragments** | A selected `claude_mcp_fragment` is merged into the project's`.mcp.json`. Claude asks to approve new servers found there; that prompt stops startup as `mcp_gate` until someone answers it and runs `rig seat continue`. Otherwise use `/mcp` or `claude mcp add`, or describe the servers in startup files for the agent to configure. |
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
### The Approach: Describe, Then Let the Agent Handle It
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
@@ -250,12 +426,18 @@ OpenRig performs best-effort deterministic runtime configuration for managed ses
installations may retain one; existing user settings are not removed.
- Claude global state: `~/.claude.json`
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
Explicit permission modes use the launch-selected `HOME/.claude.json`, or
`<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
retains the daemon-home path.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- Codex global config: `~/.codex/config.toml`
- Codex global config: `$CODEX_HOME/config.toml`, or `~/.codex/config.toml` when unset
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
- Global skill folders: `~/.claude/skills` and `~/.agents/skills`
Purpose: daemon startup seeds the `openrig-skills` and `refocusing` skills and the person-facing `rigs` skill. Each
upgrade refreshes the `rigs` skill; a copy installed another way is left as it is.
Two important caveats:
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
@@ -266,6 +448,39 @@ can affect native configuration, but OpenRig's launch flags can override those
values. Recording a config-surface `permission_policy` is not proof that its
rules were translated or applied. See [permission precedence and limits](getting-started.md#custom-settings-and-precedence).
### Separate Codex homes for separate installations
Set an **absolute `CODEX_HOME` in the environment that starts the daemon** when
you want a separate Codex configuration and state root. OpenRig uses that root
for its startup hook setup/removal and trust records, feature flag, workspace
trust and selected config fragments. Startup can write those files even when
the daemon has no seats.
The explicit selection also applies to future managed Codex fresh, resume and
fork launches, their capability/profile/configuration probes, native thread and
context reads, and plugin-cache discovery. Launch commands reassert it after
pane shell startup, so an rc file cannot silently switch that launch to another
Codex home. Executable and PATH selection stay the same. Existing running
sessions are not moved or restarted; a thread in another home is not searched
as a fallback for an explicitly selected home.
**With `CODEX_HOME` unset, a second install still shares `~/.codex`.** Setting only
`OPENRIG_HOME` separates OpenRig state, not provider configuration. The default
launch behavior is unchanged, including existing pane-shell overrides.
OpenRig does not copy or migrate authentication, history, rules, trust or
settings into the selected home. A separate file-backed home needs its intended
authentication provisioned if absent; OS credential stores and other config
layers can behave differently, so a new login is not universally required.
Explicit Codex auth commands resolve their **caller's**`CODEX_HOME`, otherwise
`$HOME/.codex`; contacting a daemon with `OPENRIG_URL` does not retarget auth
save/switch/registry writes to that daemon's selection. Set the intended root
on those commands as well.
This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Runtime Differences
**Claude Code:**
@@ -283,8 +498,85 @@ rules were translated or applied. See [permission precedence and limits](getting
- Approval policy and sandbox access are separate controls. OpenRig's default
`-s workspace-write` selects the sandbox; it does not force `-a`. A member's
`codex_config_profile` selects native `-p`, distinct from the AgentSpec `profile`.
- On that plain `-s workspace-write` launch (fresh, resume, fork or restore), a
short-lived `codex app-server` first reads Codex's own configuration with the
seat's executable, home and working directory. OpenRig adds
`-c sandbox_workspace_write.network_access=true` only when no configuration
layer sets network access and no managed requirement could restrict it. A
timeout, error or unrecognized answer adds nothing. Named profiles and full
bypass are not read. The read may write Codex's own state files in
`CODEX_HOME`, read its login and fetch managed policy, as a Codex start does.
- Managed launches pass `-c check_for_update_on_startup=false`, so Codex skips its startup update check; your Codex
config is not changed.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
`policies.claude_compaction.*` automatic policy, and the watchdog jobs that the `managed-compaction` and
`apprentice-handover` strategies create. A Codex seat compacts with Codex's own automatic compaction, and OpenRig
leaves that to Codex.
- `rig compact <session>` refuses a Codex seat: "not a Claude (claude-code) seat".
- `rig compact-plan` lists a Codex seat as skipped (`codex_not_managed_by_claude_compact_in_place`).
- The automatic policy never acts on a Codex seat.
- A Codex member with `compaction_strategy: managed-compaction` or `apprentice-handover` gets no watchdog jobs.
**What Codex compacts at.** Codex describes `model_auto_compact_token_limit` as the "token threshold that triggers
automatic history compaction (unset uses model defaults)". Its configuration reference does not say what those
defaults are, and the model catalog's `auto_compact_token_limit` is null for the models we checked in Codex 0.162.1.
Measure a seat's own compactions before relying on an unset limit.
**What window the model has.** `codex debug models` prints the catalog. Each model carries `context_window`,
`max_context_window` and `effective_context_window_percent`. In Codex 0.162.1, `gpt-5.6-sol` has a `context_window`
of 272000 at 95%, which is 258400. That is the `model_context_window` its rollout `token_count` events report.
A limit only matters when it is below that effective window. Leave `model_context_window` alone: don't raise it toward
`max_context_window` to avoid compaction. Set the limit instead.
**Setting the limit for seats only.** Put `model_auto_compact_token_limit` in a Codex profile file,
`$CODEX_HOME/<name>.config.toml` (`~/.codex/<name>.config.toml` when `CODEX_HOME` is unset), and name it on each
Codex member that should use it:
```toml
# ~/.codex/seat-compaction.config.toml
model_auto_compact_token_limit = 150000 # choose your own value
```
```yaml
codex_config_profile: seat-compaction
```
OpenRig passes `-p seat-compaction` on that seat's fresh, fork and resume launches. Codex layers the profile file on
top of the base user config, so only seats that name the profile see the lower limit. A Codex session started without
that profile keeps the base `config.toml` in the same Codex home (`$CODEX_HOME/config.toml`, or `~/.codex/config.toml`
when `CODEX_HOME` is unset). The `150000` is an example, not a recommendation.
With the normal launch mode, `codex_config_profile` replaces OpenRig's explicit workspace-write sandbox flag, so the
profile must set the sandbox and approvals you want. See
[permission precedence and limits](getting-started.md#custom-settings-and-precedence), which also gives Codex's order
of precedence between a profile, project settings and CLI overrides.
**The trade-off.** Every model call carries the whole context so far, so a smaller context makes each call cheaper. The
cost is that Codex compacts sooner and more often, so more of the conversation is carried as a summary instead of
verbatim. A higher limit keeps more detail and makes each call larger.
**Related keys.** These also shape compaction. This guide doesn't recommend values for them. See Codex's
| `model_auto_compact_token_limit_scope` | `total` (the default) counts the full active context; `body_after_prefix` counts only growth after the carried compaction-window prefix. |
| `compact_prompt` | Inline override for the history compaction prompt. |
| `experimental_compact_prompt_file` | Loads the compaction prompt override from a file (experimental). |
| `tool_output_token_limit` | Token budget for storing individual tool or function outputs in history. |
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
For seats sharing a working directory, keep each role in its own AgentSpec startup entry:
```yaml
startup:
files:
- path: guidance/role.md
orientation: role
delivery_hint: send_text
required: true
```
`rig queue whoami --json` reports only the calling seat's explicitly marked role
bindings, independently of its current work. The `role.state` is `no-record`,
`not-declared`, `missing`, `present`, or `unknown` when identity, storage or the
observation is unavailable. Files retain their original `path`, `absolutePath`
and `ownerRoot` plus the currently resolved `resolvedPath` and
`resolvedOwnerRoot`. Built-in packaged paths follow the running installation;
existing development-checkout paths stay where they were recorded.
`recordedAt` is the startup-context record write/attempt, which happens before
launch succeeds. Exact resume can retain an older record. `present` means the
resolved file currently exists, not that its bytes still match the spec or that
the agent read it. Unmarked startup files and startup actions are not exposed.
Refocus includes the role path and a re-read reminder with topology or both trees,
even if the trace fails. Work-only mode omits it; disabling refocus still disables
the entire hook. Custom refocus prose keeps its existing precedence. The hook
reuses its work lookup, or makes one bounded lookup when the work node is explicit;
a failed or skipped lookup reports `unknown`.
The shipped kernel seats no longer project their role into shared guidance.
Existing user files and legacy role blocks are left intact; this is not a cleanup
of already-installed shared blocks. General user-authored guidance projection is
unchanged.
The other shipped agent specs that start a seat with `guidance/role.md` mark that
file `orientation: role` too, so `rig queue whoami --json` reports their role and
refocus names it. They never projected a role into shared guidance; their startup
delivery is unchanged.
**Removing a leftover shared role block by hand.** A kernel installed before 0.6.5
may still hold one seat's old role in its working directory's `CLAUDE.md` (or the
rig's configured `managed_blocks` file) or `AGENTS.md`. Upgrading doesn't remove it.
`rig down` removes OpenRig's managed blocks from that file when no other running
rig uses it, so it stays until then. To remove it sooner, by hand:
1. Find the exact block: the lines from `<!-- BEGIN OpenRig MANAGED BLOCK: role -->`
through `<!-- END OpenRig MANAGED BLOCK: role -->` (in older files, `RIGGED`
instead of `OpenRig`). Only the block whose id is exactly `role`; leave
`rig-role`, other ids and everything outside the markers alone.
2. Confirm each seat that shares that directory has its per-seat replacement:
`rig queue whoami --json` run in the seat reports `role.state` as `present`.
A seat started before it was upgraded still has the old content in its
conversation; restart it fresh if you want it to start from the per-seat role.
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
@@ -320,7 +669,7 @@ This prompts the agent to actually invoke the skills, not just have them as pass
After identity recovery, verify:
1. `rig ps --nodes` shows your rig running (scoped to your session's rig by default; outside a managed session name it explicitly: `rig ps --nodes --rig <name>`)
2. `rig env status <rig>` shows services healthy (if applicable)
3. Your working directory is correct
4. Required tools are available (node, npm, git, etc.)
@@ -356,7 +705,7 @@ If your rig REQUIRES a hook to function and the hook installation fails silently
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] Reference it in `startup.files` with `orientation: role` and `delivery_hint: send_text`. Avoid projecting distinct seat roles into one shared `AGENTS.md` or `CLAUDE.md`.
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
- [ ] Choose skills from the shared pool via profile `uses`
- [ ] Write a `startup/context.md` if the agent needs environment grounding
| Before-install view | `bundle-behaviour.v1` (`openrig.bundle-behaviour/v1`) | the registry, one file per configuration and assembler version; never inside the archive | the daemon's inspect: `rig bundle inspect`, under `behaviour` with `--json` |
| Run record | `run-record.v1` | beside its receipt, private | Fleet, Dev QA, and maintainers for community reports |
| Public status | `bundle-status.v1` | `openrig-registry` status, generated | the status generator only |
- name: Sign in to the selected coding runtimes before starting the team.
```
Local-folder creation and GitHub-link imports carry these declarations into the archive manifest. A generated
before-install view lists each as `needs[]` with `kind: precondition`, the author's `name`, optional ordered `commands`,
`status: not_checked`, and a `sourceRefs` entry naming `bundle.yaml` and `preconditions[index]`. Inspect, plan and apply
previews show the same declaration. A legacy schema-1 archive retains it in the manifest, but its view remains
`not_generated`. Missing preconditions mean none were declared, not that no setup is needed.
Each command is one plain command line, without shell operators (`;`, `&&`, pipes, command substitution, backticks or
redirection). Run them in order in **one shell**, then run the install command in that same shell: it inherits the
working directory left by the setup commands. There is no separate `workdir` field. Commands are untrusted author data;
a site building a copyable block must validate every line and bound the number of entries, omitting all setup lines
if any fails its plain-command rule. OpenRig displays these declarations; it does not execute them, verify that setup
was done, or add a prerequisite gate. The archive hash covers the manifest; `packageDigest` excludes `bundle.yaml`
and therefore does not cover these declarations.
At creation, malformed preconditions produce a warning and the whole block is omitted; other author-field validation
is unchanged. Shell operators in otherwise valid command lines produce a warning, but the declarations are retained
as data. An already-built archive with malformed preconditions reports them as unknown before launch.
### Run records and status
**A run record's subject** is either a team (source, configuration ID, package digest, assembler) or a harness check (a
runtime). A harness check never upgrades a team label.
**A run record's `environment.platform` and `arch`** are Node's `process.platform` (`linux`, `darwin` or `win32`) and
`process.arch` (`x64` or `arm64`): the values a status platform key can carry, so every valid record can be labelled.
**Records are private.** Only `outcome.publicNote` (a short public cause) reaches the status file.
**Relations:** `supersedes`, `withdraws` and `resolves`. Reusing earlier evidence for a changed package is a reviewer's
decision, recorded in the registry entry's `evidenceReuse`.
**The status file** is generated from records by rule `openrig.status-rule/v1`:
- **Labels:**`known_problem`, `tested`, `tested_with_help`, `partly_tested`, `not_tested` and `status_unavailable`.
They're shown as Known problem, Tested by OpenRig, Tested with help (N), Partly tested, Not tested by OpenRig
and Status unavailable.
- **A label belongs to one configuration ID.** No configuration is ever shown another configuration's label.
- **Empty `platforms`** means Not tested by OpenRig. So does a custom configuration, one the listing doesn't offer.
- **Status unavailable is only for a listed configuration** whose records can't be read, or that's missing from the
status file; never Not tested by OpenRig. A listing whose records can't be read is `status_unavailable` as a whole.
- **Platform keys** are `<os>-<arch>` (`linux`, `darwin` or `win32`; `x64` or `arm64`).
- **`tested` means no help was needed:** it can't carry an assistance count above zero.
- **An assist** is one action a person or the test runner took to get past a problem during the run, such as restarting a seat or re-running a command; following the bundle's own documented steps doesn't count.
- **The file holds no private paths, host names, row IDs, account names or receipt text,** and regenerating it gives
identical bytes.
- **`bodyDigest`** is SHA-256, lowercase hex, over the RFC 8785 (JCS) canonical JSON, as UTF-8, of an object holding
every other top-level field. The site import recomputes it and rejects a hand-edited file. `harnessChecks` has its own
`state`, so an unreadable harness-check record reads Status unavailable too.
### Registry entries
Each entry records:
- the source at its reviewed commit;
- each offered configuration with its package digest, assembler and behaviour-view file;
- optional `evidenceReuse`;
- the review date, and `listed` or `withdrawn` (with `withdrawnOn`).
v1 has no author field: a listing shows the owner of `source.repository`.
A listing shows "Reviewed for listing on `<date>` at commit `<short>`. Review is not a security audit."
| Unit tests | `npm run test:workspaces` | `packages/daemon` + `packages/cli` + `packages/tui` |
| UI unit tests | `npm run test:ui` | `packages/ui` vitest. NOT part of `npm test`, but every pull request runs it as `package-tests (ui)` (the `package-tests` matrix in `.github/workflows/tests.yml`) |
| Installed-package scenario | CI only: `scripts/run-pr-scenarios.sh` | the installed package, a daemon restart and a seeded durability failure. The script refuses to run outside GitHub Actions unless given `--remote` with `DOCKER_HOST=ssh://…` |
| Package file lists | CI only | records each package's published file list (`npm pack --dry-run`) |
`npm test` runs `test:repo` and `test:workspaces` — the blocking set is readable in the
script itself.
`npm test` runs `test:repo` and `test:workspaces`; `npm run test:ui` is the separate UI
suite. Both sets are readable in the root `package.json` scripts.
## Advisory lane
| Lane | Command | Meaning |
|---|---|---|
| UI unit tests | `npm run test:ui` | runs `packages/ui` vitest at will; NOT part of `npm test` |
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings never fail it (`.github/workflows/portability-report.yml`) |
## The norm (web-UI freeze at 0.5.0)
Daemon API changes no longer require UI sync or UI verification; the contract mirrors
under `packages/ui/src/hooks/` are no longer proactively maintained; a new `test:ui`
failure signals a moved API contract, not a broken gate.
Daemon API changes no longer require browser or interaction verification of the UI, and
the contract mirrors under `packages/ui/src/hooks/` are no longer proactively maintained. A
new `test:ui` failure usually signals a moved API contract. Because pull-request CI runs the
UI suite as `package-tests (ui)`, the change that moves the contract has to update the affected
UI test or mirror to pass that check.
Browser/interaction testing of the web UI is not a contributor gate. (The packaged
starter-rig agent skills that exercise the UI are product content for user rigs, not
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
---
@@ -17,17 +16,18 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the validator:
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
| `delegates_to` | yes | **yes — launch order and queue escalation** | Source delegates work to target |
| `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 |
## What Edges Actually Do Today (OpenRig 0.1.x)
## What Edges Actually Do Today
### Launch ordering
@@ -36,10 +36,16 @@ Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the
- **`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.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails with `cycle_error` before the rig is created. A `delegates_to` or `spawned_by` edge from a member to itself counts as a cycle. Restore uses the same sort but has no cycle check of its own.
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
### Queue escalation
`delegates_to` also names a seat's orchestrator for the queue: the source of the `delegates_to` edge into a seat. The
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.
### 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.
@@ -50,13 +56,15 @@ All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges
### Attach hint heuristic
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) doesn't use edges. It names the first
node, in creation order, whose session is running, or else the first node with a session name.
## What Edges Do NOT Do Today
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
This page is for an agent whose user says OpenRig isn't working. It is the one place OpenRig keeps this guidance. The
copy installed with OpenRig matches that installed version. Read it with `rig context get help`. If `rig` won't run,
open `daemon/docs/reference/help.md` inside the installed`@openrig/cli` package (under `npm root -g`); in the source
copy installed with OpenRig matches that installed version. Read it with `rig context get help`, which needs a running
daemon. If `rig` won't run or the daemon is down, open `daemon/docs/reference/help.md` inside the installed
`@openrig/cli` package (under `npm root -g`); in the source
repository the same file is `docs/reference/help.md`. The same text is online at
[openrig.dev/help/agents](https://www.openrig.dev/help/agents), next to the ways people can contact OpenRig support.
Each guide this page links to is installed too. Load one only when you need it, with the `rig context get` address
shown beside its link; the same files sit beside this one in `daemon/docs/reference/`.
shown beside its link where there is one; every linked file also sits beside this one in `daemon/docs/reference/`.
Start with what your user was trying to do. Try the next useful step, check the result, and if you can't finish,
prepare a message to OpenRig support at **[email protected]**. Your user doesn't need a GitHub account.
@@ -23,7 +24,10 @@ rig doctor --json
```
Use the installed command's `--help` if an option is unavailable. Read the diagnostic findings; don't treat them as
instructions to reset the machine.
instructions to reset the machine.`rig doctor` checks Claude and Codex authentication using the same local checks
as setup, including a configured Codex provider credential variable. A set variable does not prove that the provider accepts it or that managed
seats receive it. Doctor does not run an agent task: also inspect `rig ps --nodes --rig <rig>`. If the daemon is involved, `rig daemon status` and `rig daemon logs` show its state
and recent output.
**If OpenRig won't install or `rig` won't run, start here anyway.** Record the attempted package version, install
command and error. Leave unknown values unknown. A working daemon or a passing diagnostic is not required to ask for
@@ -32,17 +36,58 @@ help.
## Match the guidance to the installed version
The reference documents beside this file describe the version they were installed with. GitHub's default branch can
contain changes that haven't reached your user's version. For release notes and known limitations, open
`https://github.com/mvschwarz/openrig/blob/v<version>/docs/releases/v<version>.md`, using the version from
`rig --version`. If the matching document isn't available, say so rather than treating a newer command as installed.
contain changes that haven't reached your user's version. After an upgrade, start with the short note on what changed
in the installed version: `rig context get reference/whats-new.md`. For full release notes and known limitations, open
`https://github.com/mvschwarz/openrig/blob/v<version>/docs/releases/v<version>.md`, using the version number from
`rig --version` (without the commit it may show in parentheses). When that file doesn't exist, use the version's
section of `https://github.com/mvschwarz/openrig/blob/v<version>/CHANGELOG.md`. If neither is available, say so
rather than treating a newer command as installed.
## Find your next step
### Installation or platform problems
Supported platforms are macOS and Linux. Native Windows is not supported yet, and WSL2 has not been tested. OpenRig needs
Node.js 22 or 24 and tmux. A WSL error needs its actual versions, commands and errortext; don't assume a
Windows-related pull request fixes it.
Supported platforms are macOS and Linux, and WSL2 on Windows; native Windows isn't supported. OpenRig's automated tests
don't run on WSL2 yet; one user's [reported working setup](getting-started.md#wsl2-a-reported-working-setup) lists what
mattered. OpenRig needs
Node.js 22 or 24 and tmux. A Linux distribution's own Node.js can be older; check `node --version`. With npm 11 or
later, an `npm warn install-scripts` line for `@openrig/cli` means only the postinstall Node.js and SQLite check was
skipped; `node "$(npm root -g)/@openrig/cli/scripts/check-abi.mjs"` runs it. A WSL error needs its actual versions,
commands and error text; don't assume a Windows-related pull request fixes it.
The one-command install ([getting-started](getting-started.md#install-and-sign-in)) prints its plan with
`--dry-run` and changes nothing. When a step fails it prints `FAILED [n/4] <command or check> (exit <code>)` and
stops. Read its diagnostic: if it names a runnable command, run that command by hand for the full error and record it
in a report; otherwise follow the accompanying diagnostic. If the only remaining failures are provider sign-ins under
"Some steps need attention", the install steps finished: sign in to each selected provider and continue.
### Installation finished, but there is nobody to talk to
Load the `rigs` skill and follow its step 2: it opens the OpenRig view (`rig terminal open saved:kernel --window`),
gives the attach commands when the window can't open or over SSH, and hands the person's goal to the operator.
[Install and sign in](getting-started.md#install-and-sign-in) says how to load it. The reference for the view, manual
attachment and the table of what can interrupt it is
[Open the kernel conversations](getting-started.md#open-the-kernel-conversations)
(`rig context get reference/getting-started.md#open-the-kernel-conversations`). Started is not ready: the view can
open while the kernel's agents finish starting, and opening it creates no other kernel or account.
### A step interrupted setup or the OpenRig view
Name the exact command and result, the reason it stopped and the next useful step. A harness's permission rules,
sandbox or automatic permission decision can refuse an installing agent's tool call before OpenRig runs.
Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config); explain the reported
refusal and let the person review that step in their harness controls or run it themselves, within their chosen
permissions. Do not silently claim installation failed or completed from a refused call.
For missing tools, downloads or selected logins, use the specific setup hint. A Herdr install warning leaves plain
tmux available. A requested Ghostty install can fail setup; resolve it or decline with `--no-ghostty` and keep the
earlier choices. Terminal.app remains available; required tools and logins still matter. macOS may ask for
Automation permission; a missing display or remote daemon needs the documented headless/manual route.
Herdr may show an intro (Return to continue) and an agent-integration panel (Esc to close); the view does not
require installing those integrations. Enlarge a cramped 80×24 window. Use authorized desktop tools to inspect the
window contents, or state that visibility is unconfirmed and ask the person to check. A successful command or
window listing alone is not visual proof. The [OpenRig view guide and friction table](getting-started.md#open-the-kernel-conversations)
connects each observation to its next step. Preserve existing conversations while resolving it.
### The team did not start, or a terminal is missing
@@ -58,6 +103,7 @@ Read [Have your agent configure permissions](getting-started.md#have-your-agent-
(`rig context get reference/getting-started.md#have-your-agent-configure-permissions`). Identify the
specific prompt or sandbox restriction. Work within the user's chosen permissions; don't switch the whole environment
to unrestricted access to clear one prompt.
For a `403` naming `untrusted_host` or `browser_origin_refused`, see [Browser access and allowed addresses](browser-access.md).
### A seat shows attention, waiting or a failed restore
@@ -66,6 +112,14 @@ and `rig ps --nodes --rig <rig-name>`, then compare the reported state with what
A waiting prompt, a failed launch and an agent working behind a stale status need different next steps. Repeatedly
clearing attention doesn't fix an underlying readiness problem.
A fresh seat stopped at a native prompt (Claude's bypass-permissions warning, a login or a folder-trust question)
keeps its startup context. `rig up`, `rig bundle install` and `rig ps --nodes` print "Startup attention" or "Startup
details" for it, ending with the command to run. Once your user has answered the prompt in that seat's terminal,
`rig seat continue <seat>` delivers the context in the same conversation, without relaunching. If it reports an
unknown outcome, check `rig seat status <seat>` before trying again. For a rig whose seats bypass permissions,
`--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.
### You can't tell which instance or configuration is involved
Read [instance layout](instance-layout.md) (`rig context get reference/instance-layout.md`) and
@@ -77,12 +131,18 @@ involved before proposing changes.
Compare the harness version and what you actually observe before attributing a failure to one of these. Check the
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).
- Claude usage limits are not detected: [#98](https://github.com/mvschwarz/openrig/issues/98),
--continuation "check <what>; tell <person> if met, else re-arm 1h"
```
`--on` is always required; `external:<what>` names the outside thing you're waiting on. The timer first fires after
the interval, not now.
**4. Say back what you set, in one or two plain lines:** what you'll check, how often, how they'll be told, and when
it stops. For example: "I'll check the release PR every hour and message you here once it merges. Then I'll stop."
A plain reminder ("remind me in 20 minutes to look at the release notes") is the same with no condition. Set it aside
with `--wake-after 20m`, and when it fires, tell them and close it.
## Every time the timer fires: read the record first
When the timer's message arrives, **run `rig queue show <task> --full --json` before doing anything.** A message already
on its way can still arrive after the watch was stopped or changed. If the task is no longer set aside (done, canceled, or replaced),
do nothing.
Otherwise, check the condition, then do exactly one of these:
| What you found | Do |
|---|---|
| **Met, and the watch was "once"** | Tell the person (below), then close: `rig queue update <task> --state done --closure-reason no-follow-on --note "<what you saw>"` |
| **Met, and it's "every time"** | Tell the person, then re-arm |
| **Not met** | Re-arm. Say nothing to the person: never send "nothing yet" |
| **Can't tell** (no fresh reading) | The first time, tell them once that you can't tell, and why. Record that in a note (`Unknown: said <time>`), then re-arm. **Never say "all fine" for an unknown.** Stay quiet on later unknowns until the reading returns or changes |
**Re-arm** by setting it aside again with a fresh timer:
- **Check that the number of tasks returned is below the limit.** The limit applies before you filter by tag. If you got
exactly the limit, raise it and list again. **Never report "no watch" from a page that was cut off.**
- **Use `--full`.** Compact output leaves out the body and the waiting view.
For each watch, say what it checks, how often, how they'll be told, and the next check. The next check is
`waiting.nextBackstop.dueAt` when `waiting.nextBackstop.mechanism` starts with `watchdog:`. If a set-aside watch shows
no timed backstop instead, its timer has fired and wasn't re-armed. Check it now and re-arm it.
Answer from the records, so a second reader would give the same list.
## Changing or stopping a watch
| The person says | Do |
|---|---|
| **"Check every 2 hours instead"** | Re-arm with the new interval and a note saying what changed: `rig queue block <task> --on external:<what> --wake-after 2h --note "<person's words>"` |
| **"Watch Y instead"** (what's checked changes) | A task's body can't be edited. Create the new watch, then retire the old one: `rig queue update <old> --state canceled --closure-reason superseded --closure-target <new> --note "<person's words>"` |
| **"Stop watching that"** | `rig queue update <task> --state canceled --note "<person's words>"`. Leaving the set-aside state retires its timer. A plain cancel takes no other closure fields |
Tell the person in one line what changed. After a stop, take no further action for that watch.
## Across a restart or handover
A set-aside watch's current timer is **kept** when the operator is restarted or replaced, and it fires at whoever is
the operator next. That's why the body must stand alone.
**One gap:** a timer fires once. If the operator is replaced **after a fire and before the re-arm**, the successor
inherits a set-aside watch with no live timer. OpenRig's idle-owner check may remind the new operator once, but only
under some conditions, with no delay guarantee. So:
- **Re-arm promptly** after every fire.
- **After a restart or handover, list your watches** (above) and re-arm any that show no timed backstop.
- **Handing a watch to another session** (`rig queue handoff <task> --to <session>`) closes the old task and retires its
timer (unless another task shares that timer through `--wake-watchdog`; a watch set up as above never does). The new
task arrives pending, with its body and `watch` tag but no timer: the new owner sets it aside again with a fresh
timer, as in step 3.
## A usage warning: "warn me when my allowance runs low"
This is a conditional watch like any other: the same task, timer, re-arm, stop and human channel. Only the check is
specific.
**The check is `rig provider status --all-hosts --json`.** It returns what every registered machine already holds,
this one included, and never starts a read on any account. With no host registry it covers this machine only, and
says so. Use it rather than the single-machine `rig provider status --json`: one allowance used from several
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.
**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.
**Choose the allowance with the person once, and write its identity into the body.**
- **The allowance:** its `items[].key` and `items[].provider`, read from one run of the check. Show them the allowance
by its label in the plain `rig provider status --all-hosts` view, never a key they have to type. An item whose key
is null (a login that hasn't been identified) can't be watched: say so, and offer to watch it once it's identified.
- **For Codex, the login email too:**`items[].observations[].row.identity.email`, shown on each machine's line in the
plain view. The same Codex account can appear as more than one item with the same key, one per login email (or none)
it was seen under. So on each fire, use the one item whose key matches and whose observations carry the recorded
email. If none or several do, you can't tell. A Codex allowance whose login email is missing can't be pinned this
way, so it stays can't tell. A changed email isn't followed: if the person's login email changes, set the watch
again with them.
- **The window:**`items[].observations[].row.windows[].window`. Claude's are `five_hour` and `weekly`; Codex's are
`primary` and `secondary`, with `items[].observations[].row.windows[].durationMins` saying which is which.
- **The threshold:** on `items[].observations[].row.windows[].remainingPercent`. It's a percentage, and no absolute
amount is served. Remaining is 100 minus `items[].observations[].row.windows[].usedPercent`, never below 0. Both are
null when the machine reported no usage for the window.
**What each part of the answer means:**
| Field | What it tells you |
|---|---|
| `items[].linkage` | `single`: one machine saw it. `same`: several machines saw one allowance. Compare their observations; **never add or average them**. `conflict`: the machines' fresh observations disagree (their reset times are more than 60 s apart); every observation is kept and none is chosen. `unknown`: the same id seen under another login, or with no login email, and not merged with it. `items[].notes` says why. For Codex, the one item carrying the person's recorded key and email is still theirs: its `unknown` is about the other login's item (see the decision). Any other `unknown` item is not theirs to watch |
| `items[].observations[].hostId`, `items[].observations[].readAt` | Which machine the observation is from, and when this daemon read it |
| `items[].observations[].row.windows[].observedAt` | When that machine's reading was taken: the time to tell the person |
| `items[].observations[].row.windows[].resetsAt` | When that window resets: tell the person this too |
| `items[].observations[].row.windows[].freshness` | `fresh`, `stale` or `unavailable`, with `items[].observations[].row.windows[].freshnessReason` saying why. A reading is fresh for 15 minutes |
| `items[].observations[].row.windows[].level` | The served level, with `items[].observations[].row.windows[].levelReason`. Compare the person's threshold, not this level. But **`unknown` means the number can't be used**: no usage was reported, the window's reset time has passed (the number is from before it), or Claude seats report different windows (possibly different logins) |
| `items[].observations[].row.lastRefresh` | **Codex:** that machine's last native read. When `items[].observations[].row.lastRefresh.outcome` is `failed`, the reading it kept is older and reads `stale`. **Claude:** the last read of the daemon's own Claude login (its identity) only. Claude's windows come from the seats' status-line readings at their own times, so they can stay `fresh` beside a failed identity read |
| `items[].observations[].row.identityNote` | **Claude:** which login the identity is (as of the last identity read) and which seats the windows came from. The windows aren't proven to be that login. Say so when you tell the person |
| `hosts[].usage`, `hosts[].usageReason` | Whether each machine's usage was read: `served`, `off`, `older` or `unavailable`, and why. A machine that wasn't read contributes nothing, never a zero |
| `hosts[].lastRefresh` | The last refresh started from here for that machine, per provider. A `failed` one sits beside the older observation the machine kept |
| `seatAccounts[].seats[].configured`, `seatAccounts[].seats[].allowance` | The account each seat is **configured** to use. `seatAccounts[].seats[].allowance.linked` is always false: configuration isn't proof of which allowance a running seat draws on, or that a Claude seat's windows are the identity's login. Never tell the person a seat uses this allowance because of it |
| `seatAccounts[].seats[].observed` | What the seat itself was seen reporting: whether its status line has sent windows and when (`seatAccounts[].seats[].observed.lastSampledAt`), and `seatAccounts[].seats[].observed.differsFrom`, the seats whose readings disagree with it. Observed facts, still not a link to an allowance |
**The decision on each fire,** using the table in "Every time the timer fires". A usable reading is a `fresh`
observation of the chosen window whose `level` isn't `unknown`.
- **Met:** a usable reading is below the threshold. Tell them the lowest one, with its machine, `observedAt` and
`resetsAt`. If the linkage is `conflict`, also tell them the machines disagree, from `items[].notes`, rather than
presenting the lowest as the reading.
- **Not met:** every usable reading is at or above the threshold, and there is at least one.
- **Another login on the same Codex account:** when the item matched by key and recorded email has `items[].notes`
saying the account is also observed under another login email (or with none), its linkage reads `unknown` on one
machine, or `same` on several. Use its own usable readings as above. When you tell the person, say that login's
observations are unlinked: not added, and not merged.
- **Can't tell:** no usable reading: none `fresh`, or the fresh ones' `level` is `unknown`, which includes a null
`remainingPercent`. Also when the allowance is missing from the answer or its key is null, or (Codex) no login email
was recorded, or no item, or more than one, carries both the key and the recorded email. Use the once-then-quiet
unknown rule, and give the reason (`freshnessReason`, `levelReason`, or the machine's `usageReason`).
The task body, for the person's own allowance, window and threshold:
```markdown
Person's words: "Warn me when my Claude allowance drops below 20% of its 5-hour window; check every 30 minutes."
Check: `rig provider status --all-hosts --json` (served readings only; never --refresh).
Allowance: key <allowance key>, provider claude, window five_hour. Chosen with the person.
(For Codex, also: login email <email shown in the label>.)
Usable: a fresh observation of that window whose level isn't unknown.
Met: a usable reading with remainingPercent below 20. Report the lowest, with its machine, observedAt and resetsAt.
Never add machines' numbers. If the linkage is conflict, say the machines disagree (items[].notes).
Claude: say the windows come from seats' status lines and aren't proven to be the identity's login (identityNote).
Codex: the one item with the recorded key and email is theirs even if its linkage is unknown; say the other login's
observations are unlinked (items[].notes).
Can't tell: no usable reading (none fresh, or level unknown: give its levelReason), the allowance missing or its key
null, or (Codex) no email recorded, or no item, or more than one, carries both the key and the recorded email. Give the
reason.
Interval: 30m.
Tell them: <entityId>@external (human channel), once, when met.
Stop: after telling them once, or when they say stop.
Unknown: not said yet.
```
## What not to use
- **`rig watchdog register --policy periodic-reminder` for a person's watch.** It repeats every interval until it's
stopped (the first fire comes one interval after you register it), and it stops when the agent that registered it
is replaced. It's fine for agent-to-agent reminders. Your own watch timers also show as `periodic-reminder` in
`rig watchdog list`: that's the one-shot timer `--wake-after` arms, not a registered repeating reminder. It's retired
when it fires or when the task stops being set aside. A wake held back because you were typing (a retained
delivery) wasn't delivered, so it doesn't use the timer up: it's tried again at each interval until an attempt goes
through (delivered or failed), which retires it. A timer another task shares through `--wake-watchdog` stays live
for that task when this task is handed off or stopped, and the same rule applies to its wake. None of this is a
delivery guarantee.
- **Transcript bytes as "usage".**`context-usage-threshold` measures an agent's transcript, not an account's
allowance. For an allowance, see "A usage warning" above.
Then open a new terminal and run `nvm install 22`. If nvm's installer says you need Apple's command line tools, run
`xcode-select --install`, then its install command again. That's nvm v0.40.8's install command; [nvm's
page](https://github.com/nvm-sh/nvm#install--update-script) has the newest.
## What happens, step by step
Each step shows the command, so you can run it yourself.
### 1. Install the `rig` command
```sh
npm install -g @openrig/cli
rig --version
```
This installs the CLI globally under your npm prefix. `rig --version` prints the version and a commit, such as
`0.6.12 (9e5382dd)`. With npm 11 or later, an `npm warn install-scripts` line is expected: it means only the
Node.js and SQLite check was skipped.
### 2. Set up the tools around it
```sh
rig setup --providers claude
```
Use `codex` or `both` in place of `claude` to match the coding agents you use. Add `--dry-run` first to see the plan without changing anything. Setup does four things:
- it checks Node.js, tmux and your sign-in;
- it installs a missing tmux (with Homebrew on macOS, if you have it) or the coding agent you chose;
- it installs Herdr, a terminal workspace app the OpenRig view can use, unless you add `--no-herdr`;
- it adds an OpenRig block to `~/.tmux.conf` (mouse support and longer scrollback).
It doesn't start anything. It ends with a summary of what's done and what still needs you. A missing sign-in shows
there: run `claude auth login` or `codex login`, then carry on.
### 3. Start OpenRig
```sh
rig daemon start
rig status
rig ps --nodes --rig kernel
```
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
- **For 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.
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
including reads under your home folder, run without asking. A permission policy, per-agent choice or Codex profile
changes that.
OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first. [Everything OpenRig writes, and why](../../README.md#what-openrig-changes-on-your-machine).
Started isn't the same as ready. `rig status` shows a `Kernel:` line, and `rig ps --nodes --rig kernel` shows each
kernel agent's state. They can take a moment after the daemon is up.
### 4. Open the OpenRig view
```sh
rig terminal open saved:kernel --window
```
**What you'll see:**
- **Where it opens:** a new terminal window or tab. If your agent is already running inside Herdr, it opens a new
Herdr space instead. It uses Herdr when installed, plain tmux otherwise.
- **Layout:** in a narrow window, or when the width can't be measured, the operator fills the first page. From 120
columns, the dashboard and the operator share it. The advisor has its own tab.
- **On a Mac:** you may be asked to let your terminal app be controlled. Allowing it is fine.
- **Over SSH or with no display:** the window can't open. The command says why and, where it can, prints a command
to run in a new terminal. Otherwise your agent works out the exact command to join the operator.
You're installed when you're talking to the operator.
### 5. Start your first team
Tell the operator what you want built, and in which folder. It recommends one of three teams:
- `starter`: a builder and a reviewer, for one bounded change;
- `workshop`: a lead, a builder, QA and a reviewer, for ongoing work in one repository;
- `factory`: seven agents, for sustained product work.
Before it starts one, it tells you what starting a team in your folder changes: managed guidance in `AGENTS.md` or
`CLAUDE.md`, plus the team's skills. Then it waits for your yes. As shipped, `starter` uses both Claude Code and
Codex. With only one of them, ask the operator to adapt it.
By hand, from your repository (the first command shows what it would start, and starts nothing):
```sh
rig up starter --cwd . --plan
rig up starter --cwd .
rig ps --nodes --rig starter
```
If an agent stops at a sign-in, trust or permission prompt, `rig up` reports `Status: partial` and names the prompt.
Answer it in that agent's terminal, then run the `rig seat continue <seat>` command it prints. The team is ready
for work once its agents show as ready in `rig ps --nodes --rig starter`.
### 6. Give the team work, and stop it
```sh
rig send dev-build@starter 'Fix <one useful thing>. Ask dev-review to check it, and record the result and how I can try it.'
rig queue list --destination dev-build@starter
```
The builder records the work as a task, so you can follow it with `rig queue`. When you're done, `rig down starter`
stops that team. `rig daemon stop` stops the background service and leaves agent sessions running.
## The one-command install
Instead of steps 1 and 2 (the first line shows the plan and changes nothing):
```sh
curl -fsSL https://openrig.dev/install.sh | sh -s -- --dry-run
curl -fsSL https://openrig.dev/install.sh | sh
```
It checks Node.js and npm, installs the latest `@openrig/cli`, then runs `rig setup`. It doesn't know which coding agent
you use, so it checks and may install both. For just one, use steps 1 and 2. If a sign-in is missing, setup reports
it as the step to finish: sign in, then continue at step 3.
## What these words mean
- **Rig:** a team of agents, started with one command. Your first one is usually `starter`.
- **Seat:** one agent's role and address in a team, such as `dev-build@starter`.
- **Harness:** the software around the model that an agent runs in: Claude Code, Codex or Pi.
- **Kernel:** OpenRig's own small team, which starts with OpenRig. It isn't your project's team.
- **Operator:** the kernel's agent you talk to first. It recommends a team for your goal and starts it when you say
yes.
- **Advisor:** the kernel's agent for thinking through what you want before a team starts. It has its own tab.
- **OpenRig view:** the terminal view that opens on the operator. In a wide window the dashboard sits beside it;
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.
## More
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
opening. See the [setup reference](getting-started.md).
- **Stuck?** Run `rig context get help`, or read [Help](help.md).
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
## GitHub folder links
`rig bundle create`, `inspect`, `install`, and `rig up` accept a public HTTPS GitHub folder link:
minimum-version shape, declared preset consistency and known sensitive filenames. It does not contact a daemon,
build an archive, run preflight or launch anything. Findings give exit status 1; they are never an install gate. It
reads the folder on disk, not a link or a commit, so files that aren't committed are checked too. Human output gives
each rule's status, ID and reason, then the file it's about in parentheses when there is one; `--json` carries the
same as a `path` field.
README completeness and arbitrary embedded secrets need human review and are reported as `not_checked`. A bounded
or unreadable scan is also `not_checked`, never a clean scan. The check is advice for authors, not a security audit or
listing approval.
---
## Archive Format
@@ -34,7 +94,7 @@ The packer produces deterministic output:
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
This means the same inputs always produce the same archive hash.
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
---
@@ -55,6 +115,10 @@ agents/
SKILL.md — skill files
startup/
context.md — startup files
context-packs/<name>/ — a pack carried with --context-pack (its manifest.yaml and declared files)
project/ — the project carried with --project-dir (project.yaml and files beside it)
skills/<name>/ — a shared skill the author's bundle.yaml declares; other declared
plugins, workflow specs, context packs and agent images keep their paths
```
### Key rules
@@ -98,6 +162,15 @@ agents:
original_ref: "local:../../shared"
hash: "abc123..."
culture_file: CULTURE.md
provenance:
created_at: "2026-04-11T22:32:48.570Z"
daemon_version: "0.6.6"
cli_version: "0.6.6"
# ... and, for a GitHub link, the source it was built from
assembler:
openrigVersion: "0.6.6"
configuration:
id: "dev.lead=claude-code,dev.researcher=codex"
integrity:
algorithm: sha256
files:
@@ -118,7 +191,15 @@ integrity:
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
| `culture_file` | string | no | Relative path to the culture file if present. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
| `included_login_files` | string[] | no | Exact archive-relative harness login files deliberately included by the producer. Only these paths are exempt from the login-file refusal; this unsigned declaration is not authentication or receiver approval. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. Validation doesn't require it, but install refuses an archive without it and inspect reports integrity FAIL. |
| `provenance` | object | no | Stated, not verified: `created_at`, `daemon_version`, `cli_version`, `source_host`, `author_session`, `source_rig_id`, `source_rig_name`, `notes`, and `source` for a GitHub link build. |
| `assembler` | object | no | `openrigVersion` of the OpenRig that built the archive. |
| `configuration` | object | no | The configuration ID the archive was built in, and `preset` when it matches a declared preset. Outside the package digest. |
| `compatibility` | object | no | `min_cli_version` and `min_daemon_version`, checked on install. |
| `preconditions` | list | no | The author's setup declarations, shown before install and never run (see [bundle formats](bundle-formats.md#declared-setup-preconditions)). |
| `skills`, `plugins`, `workflow_specs`, `context_packs`, `agent_images` | lists | no | Contents from the author's `bundle.yaml` and `--context-pack`, routed into your libraries on install. |
| `project` | object | no | `id` and `path` of the project carried with `--project-dir`. |
Anyone with that archive could read the included login files.
The flag applies to this create only. The generated `included_login_files` manifest list names only the actual,
exact archive-relative login files included. Inspect, unpack, install and bootstrap honour those paths without
another flag and show a credential-bearing notice. An additional unrecorded login file still fails, as do malformed
records, directory or wildcard exemptions, and unrelated sensitive files. There is no machine setting or `--force`
bypass. The unsigned record states the producer's choice; it does not authenticate the producer, establish trust,
or constitute a fresh recipient approval. Review the source and files before using a bundle.
Compatibility change: previously accepted bundles containing these files now fail default creation and downstream
verification/install unless deliberately rebuilt with the record. Inspection remains diagnostic: it reports failed
integrity. Ordinary `auth.json` elsewhere and non-login harness files are unchanged. Filename checks do not scan
arbitrary contents for secrets.
### Integrity Section
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `algorithm` | string | yes | Must be `sha256`. |
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive except `bundle.yaml`, `.DS_Store`, `Thumbs.db` and `.gitkeep` is listed; an extra file that isn't listed fails verification. |
2. **No absolute paths** — entries starting with `/`, or carrying a Windows drive letter such as `C:\`, are rejected
3. **No path traversal** — entries containing `..` segments are rejected, on either slash direction (`..` or `..\`)
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
If any check fails, extraction is aborted and an error is thrown.
`rig bundle inspect` applies the same unsafe-entry scan (rules 1-3) before it extracts into its temporary directory, so an archive the installer refuses for an unsafe entry is never reported as inspectable. Digest and content-integrity failures are a different case: inspect reports those in its result (`digestValid`, `integrityResult`) instead of refusing, which is what lets it describe a broken bundle rather than throw.
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
| `<spec-path-or-github-link>` | yes | — | Path to the rig spec YAML file, or a GitHub folder link (see "GitHub folder links" above). |
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
| `--preset <name>` | no | — | Build one of the configurations the bundle declares in `configurations.yaml` beside `rig.yaml` (see [bundle-formats.md](bundle-formats.md)). The chosen runtimes and profiles are applied to an owned copy of the rig folder, never to yours. |
| `--seat <pod.member=runtime>` | no | — | Use this runtime for one seat, within what `configurations.yaml` allows. Repeatable; applied after `--preset`. An undeclared choice is refused with the allowed set, and nothing is built. |
| `--context-pack <dir>` | no | — | Carry the context pack in `<dir>`. Repeatable. The directory may be outside the rig folder, for example a world pack whose `manifest.yaml` is at its repository root. Only `manifest.yaml` and the files it declares are carried, the same set `rig context add --git` installs, and the pack lands in the bundle at `context-packs/<manifest name>/`. Needs a pod-aware spec. |
| `--project-dir <dir>` | no | — | Carry the project the rig works in: the folder holding its `project.yaml` (which must declare an `id`) and files beside it, such as `SPEC.md`. On install the project is registered in the workspace catalog and the rig is associated with it, before any seat launches (see [project-workspace.md](project-workspace.md)). Needs a pod-aware spec. |
| `--name` | no | the rig's `name` in `rig.yaml`; `my-bundle` (`github-bundle` for a link) when it has no readable name | Bundle name in the manifest. |
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
| `--min-cli-version`, `--min-daemon-version` | no | — | The oldest OpenRig CLI or daemon that may install the bundle, checked on install. For a link they override the author's `bundle.yaml` minimums. |
| `--notes <text>` | no | — | Notes stored in the manifest's provenance. |
| `--include-packages <refs...>` | no | all from the spec | Legacy (schema 1) specs only: the package refs to include. |
| `--include-login-files` | no | off | Only if recipients should have these logins and their owner agrees (for example, an internal test bundle), include them and record their exact archive-relative paths. Create and consumers show a credential-bearing notice. Does not exempt other sensitive files. |
| `--allow-drift` | no | off | Build a spec that differs from the running rig of the same name. Without it, create refuses; with it, the difference is written into the manifest's provenance notes. |
| `--json` | no | off | Machine-readable output. |
`--context-pack` and `--project-dir` exist only on create. `rig up` and `rig bundle install` have neither, so a bundle
installed straight from a GitHub link carries no project, and carries a context pack only when the folder's own
`bundle.yaml` declares one inside the folder.
The create command:
1. Validates the rig spec
2. Resolves all `agent_ref` paths and their imports
3. Vendors all agent specs, resources, and startup files into a staging directory
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
5. Collects culture file, docs files, and rig-level startup files
6. Computes per-file integrity hashes
7. Writes the manifest (`bundle.yaml`)
8. Packs into a deterministic `.tar.gz`
9. Writes the sibling `.sha256` digest
1. Validates the rig spec, and refuses a spec that differs from the running rig of the same name (see `--allow-drift`)
2. Collects the culture file, docs files, and rig-, pod- and member-level startup files
3. Resolves all `agent_ref` paths and their imports, vendors the agent specs and their resources, and rewrites the refs to bundle-relative `local:` paths
4. Adds the author's `bundle.yaml` contents (preconditions, skills, plugins, workflow specs, context packs, agent images), the carried packs and project, and the configuration and assembler stamps
5. Computes per-file integrity hashes, refusing sensitive paths (such as `.env`, `*.pem`, `*.key`, `*.p12`, `credentials.*` and `tokens.*`), and writes the manifest (`bundle.yaml`)
6. Refuses staged content that can't be shipped publicly
7. Packs into a deterministic `.tar.gz` and writes the sibling `.sha256` digest
### List a bundle's configurations
```bash
rig bundle configurations <spec-path> [--json]
```
Lists the presets that `configurations.yaml` declares, each with its configuration ID, which one is recommended, and which one matches `rig.yaml` as written.
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install or launch anything.
Shows the manifest, the archive's identity (source and commit for a link, configuration, package SHA-256 and its
coverage, assembler, archive SHA-256), digest validity, the integrity result, any carried context packs and project,
and then the behaviour view. Inspect extracts the archive into a temporary directory for safe validation, then cleans
that directory up. It does not install or launch anything. It exits 2 when the digest or integrity check fails.
`--preset` and `--seat` choose a declared configuration of a GitHub link.
The behaviour view describes the selected archive: its team and configured models, declared permission posture,
files agents receive, startup actions and hooks, managed writes, literal outside domains, prerequisites and what
remains unknown until launch. It reads packaged declarations; it does not run scripts, contact providers, probe
runtimes or create a bootstrap run. Source and assembler identity come from the archive, separately from the view
generator. Configured models are not observations of a running model, and literal domains are not predicted traffic.
Under "Needs (not checked)" the view lists the author's setup preconditions, with their commands marked "not run; one
shell, in order". For a declared full-bypass Claude seat it notes that Claude Code asks once to accept its bypass
warning, and when any Claude or Codex seat is full bypass it says that `--non-interruptive` is available (see
[non-interruptive mode](non-interruptive-mode.md)). It doesn't select the mode or check whether you've already
accepted the warning. When the bundle's `rig.yaml` declares `non_interruptive: true`, the view instead says that the
bundle declares non-interruptive launches for those seats, with broad file, command and network access, and that
`--no-non-interruptive` overrides it; the asks-once note is left out.
`rig bundle install` and bundle-form `rig up` print this same view to stderr before the existing action, including
`--plan`. With `--json`, stdout remains one result; its optional `behaviour` field carries the structured view.
GitHub links reuse the single prepared archive. The header is informational: missing views and inspection errors
print a diagnostic, then the original installation checks and result determine success. Legacy schema-1 archives
report `not_generated`, rather than an empty team. A local archive can change between inspection and installation;
the header does not bind a later action atomically to those bytes.
Agents can run shell commands as the launching user, subject to runtime and host policy. Explicit permission bypass
is distinguished from conditional launch floors; Pi resource trust is separate from native permission policy.
Host settings, accounts, plugins, runtime support and effective permissions remain unknown. Text scanning is bounded
and reports unread members; dynamically computed commands and addresses cannot be predicted. Installation targets,
`--cwd` and configured library roots resolve separately from the archive's declarations. See
[bundle formats](bundle-formats.md) for the structured view and digest coverage.
**Integrity means the archive is self-consistent, not who made it. Provenance is stated by the bundle and not verified.**
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
| `--plan` | no | `false` | Preview without installing or launching. Not side-effect free: it runs the preflight, which executes the members' runtime `--version` probes, and it records a bootstrap run. It writes nothing to the target. |
| `<bundle-path-or-github-link>` | yes | — | Path to the `.rigbundle` file, or a GitHub folder link. |
| `--plan` | no | `false` | Preview without installing or launching. Not side-effect free: it records a bootstrap run and runs the preflight, which for a pod-aware bundle runs `pi --version` or `omp --version` for Pi or OMP seats and `codex -p <profile> mcp list` for a Codex seat that names a `codex_config_profile`. It writes nothing to the target. |
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
| `--target <root>` | yes in apply mode | — | Directory the bundle is installed into and launched from. Required unless `--plan` is used. |
| `--target <root>` | yes in apply mode, for an archive path | the current directory for a link | Directory the bundle is installed into and launched from. Required for an archive path unless `--plan` is used. |
| `--cwd <dir>` | no | — | Working directory for every launched member, for this install only (for example, the repository the rig works on). Does not change the install target. |
| `--preset <name>`, `--seat <pod.member=runtime>` | no | — | For a GitHub link, the declared configuration to build and install. |
| `--non-interruptive` | no | the bundle `rig.yaml`'s `non_interruptive`, else the operator setting `launch.non_interruptive`, else off | Accept the harnesses' first-launch warnings for this rig's full-bypass Claude and Codex seats with launch flags, writing nothing to your settings. The choice is saved on the rig. `--no-non-interruptive` turns a saved choice off; stop a running rig with `rig down` first. See [non-interruptive mode](non-interruptive-mode.md). |
| `--skip-version-check` | no | off | Skip the compatibility check below. Not for routine use. |
| `--force` | no | off | Skip the running-team check below. Not for routine use. A pod-aware bundle is still refused before target files are written when a team with the same name is running; a legacy (schema 1) bundle can be left partly installed. |
Install **launches the rig**; it does not just unpack it. It extracts the bundle to a temporary directory, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
Install **launches the rig**; it does not just unpack it. It extracts the bundle to a temporary directory, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` for an archive path unless you are running with `--plan`.
For a pod-aware (schema version 2) bundle, apply copies the extracted contents (`bundle.yaml`, `rig.yaml`, `agents/`, culture and docs files) into the target and launches from there, then removes the temporary extraction. So:
Before it plans or applies, install runs two checks on the archive, both also under `--plan`:
- **compatibility:** the manifest's `min_daemon_version` and `min_cli_version` against this daemon and CLI. A failure
reports "Bundle compatibility check failed" with the versions involved.
- **running team:** a running team with the bundle's rig name refuses the install with "Bundle install conflict check
failed". The error names the installed and the offered team and lists three choices: use the existing team, stop it
with `rig down` and retry to replace it, or cancel. This check does not refuse a stopped team with that name.
- the target becomes the rig root: `agent_ref` paths resolve inside it, and a member with `cwd: "."` (or no `cwd`) starts in the target;
- an absolute member `cwd` stays as authored, and `rig up --cwd <dir>` still overrides every member's cwd;
- if the target already has a file with **different** content at any bundle path (for example its own `rig.yaml`), install refuses with `target_conflict` and writes nothing. Identical files are accepted, so reinstalling the same bundle into the same target works. Use an empty or dedicated directory as the target.
Each error lists its fixes; the compatibility error also names `--skip-version-check`. `--skip-version-check` and
`--force` skip only these two checks: the manifest safety check of its `rig_spec` and `project` entries always runs.
Legacy (schema version 1) bundles keep their old behavior: `--target` is only where packages are installed.
For a pod-aware (schema version 2) bundle, installing a team whose rig name matches a stopped team replaces it: the
earlier team is archived when the new one is created, and the result gives the `rig unarchive <id>` command that
restores it. If OpenRig cannot confirm the earlier team is stopped, install refuses with `generation_unconfirmed` and
creates no replacement team. For an install into that team's own install folder, OpenRig makes this check before
writing any files; for any other target, bundle files already copied there stay.
A legacy (schema version 1) bundle does not replace a stopped team. Its approved external and package install steps run
first, and then its rig-name check fails preflight with "Rig name '<name>' already exists".
For a pod-aware (schema version 2) bundle, apply copies every extracted file (`bundle.yaml`, `rig.yaml`, `agents/`, culture and docs files, and any carried `project/`, `context-packs/` and author-declared contents) into the target and launches from there, then removes the temporary extraction. So:
- the target becomes the rig root: `agent_ref` paths resolve inside it, and a member with `cwd: "."` starts in the target (every pod-aware member must declare a `cwd`);
- an absolute member `cwd` stays as authored, and `--cwd <dir>` (on `rig bundle install` or `rig up`) still overrides every member's cwd;
- if the target already has a file with **different** content at any bundle path (for example its own `rig.yaml`), install refuses with `target_conflict` and writes nothing. Identical target files don't cause a target-content conflict; the other installation checks above, including the running-team check, still apply. Use an empty or dedicated directory as the target.
- exception: reinstalling over a stopped team into that team's own install folder (the target's `bundle.yaml` is schema version 2 with the same bundle name, or with `my-bundle` or `github-bundle`, the default names before 0.6.7) replaces its files there. Each differing path is first copied under `<OpenRig home>/bundle-backups/reinstall-*/files/`, with a `RESTORE.json` beside it; other files in the folder are kept. The files stay installed even if a later launch fails.
The bundle's declared skills, plugins, workflow specs, context packs and agent images are routed into your libraries **before any member launches**, and the context-pack library is rescanned, so a member's first turn can already read a pack the bundle carried. A routing failure does not stop the install: it is printed as a warning, returned in `routingFailures`, and recorded in the install audit. The human output lists what each declared kind routed and any entry that was not routed.
A carried pack is routed into `context.root` under its folder name in the archive: for a pack carried with `--context-pack`, that is its manifest name (`context-packs/<name>/`); for a pack the author's `bundle.yaml` declares, it is the basename of the declared folder, whatever the manifest says. If a pack of that name is already installed, install never merges into it: an identical pack is reported as `already_installed`, and a different one (for example a `rig context add --git` install at another revision) is kept unchanged and reported as `kept_existing`, with the `rig context rm <name>` command to use the bundle's copy instead.
A carried project is registered as `registered` (a new catalog entry), `associated` (the rig added to an existing entry), `already_registered` or `conflict`. A conflict writes nothing to the catalog and is reported as a routing failure; the install continues. A project folder that already exists with different files is kept unchanged.
### Skills shared by bundle profiles
For skills shared by several agents, place each complete skill directory at
`skills/<name>/` beside the rig spec and declare its entry point in the source
`bundle.yaml`:
```yaml
skills:
- skills/review-work/SKILL.md
```
`rig bundle create` includes that directory's helper scripts and references as
well as `SKILL.md`. The skill needs valid `name` and `description` frontmatter;
the name is the ID a profile selects with `uses.skills: [review-work]`.
Undeclared sibling directories are not bundled. Other declared skill file paths
retain their single-file behavior.
Claude, Codex and Pi profiles can select this installed bundle pool even when
their working directory is elsewhere. Explicit AgentSpec resources keep priority
over a discovered skill with the same ID. Pi projects selected skills into its
seat's agent directory; it does not scan Claude or Codex home/workspace pools.
Installing the bundle does not add these skills to the operator's managed
`skills.root` catalog. Profiles that already get a skill from a plugin should not
also select its bundle copy.
When a profile selects a skill from inside its agent spec's folder (for example the
installed bundle) and the managed catalog holds a different copy with the same ID,
the bundle's copy is used. Launch warns `skill_bundle_precedence` and leaves the
catalog unchanged. The skill's `SKILL.md``name` must equal the selected ID. A
differing copy from anywhere else fails launch with `skill_identity_conflict`.
Legacy (schema version 1) bundles keep their old behavior: `--target` is only where packages are installed, and their declared contents are routed after a completed install.
### Launch directly
```bash
rig up <bundle-path> [--target <root>] [--cwd <dir>]
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
`rig up` auto-detects `.rigbundle` files and GitHub folder links and prints the behaviour view before it acts. The two
take different routes:
- **A GitHub link** builds the archive and installs it through the `rig bundle install` path, with its compatibility
and rig-name checks and the install audit.
- **A local `.rigbundle` path** goes through the bundle bootstrap path directly. It skips the compatibility check,
isn't recorded in the install audit (so `rig bundle history` doesn't list it), and doesn't print the routing summary.
For a pod-aware archive, a running team with the same name is still refused before target files are written, and a
stopped one is replaced as described under "Install a bundle". A legacy (schema 1) archive runs its approved install
steps before its rig-name check, which then refuses a running team and fails preflight for a stopped one; it also
gets no post-install routing this way. To get the checks and the audit for a local archive, use `rig bundle install`
or `rig bootstrap`.
`rig bootstrap <bundle-path>` also accepts archives and uses the ordinary bundle
install path, including compatibility checks and the install audit. Use `--plan`
to preview, or `--target <root>` to choose the persistent install directory. Its
default target is the caller's current directory; `--cwd` only changes the agents'
working directory. YAML files and library spec names keep the spec bootstrap path.
It prints no behaviour view and has no `--non-interruptive` option; a bundle's declared `non_interruptive` still applies.
- `--target <root>` is the install target described above (for a schema-version-2 bundle, the directory the bundle is copied into and launched from)
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory, so the bundle's files are written there
- `rig up` resolves a relative `--target` against your current directory before sending it, like `rig bundle install`; with `--host`, `--target` is sent as given and must be a path that exists on that host
- if `--target` is omitted for a `.rigbundle` or a link, the CLI defaults the install target to the current working directory, so the bundle's files are written there
- `rig up` resolves a relative `--target` against your current directory before sending it, like `rig bundle install`; with `--host`, `--target` is sent as given and must be a path that exists on that host, and an omitted `--target` is refused there ("targetRoot is required for bundle apply mode"). GitHub links don't work with `--host`.
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
- `--non-interruptive` works as on `rig bundle install`
- a schema-version-2 bundle's declared contents are routed before any member launches, as with `rig bundle install`
### Install history
```bash
rig bundle history [--rig <name>] [--since <iso>] [--json]
```
Lists the install audit records in `bundle-audit.jsonl` in the OpenRig home (default `~/.openrig`): when, the outcome,
the rig and the bundle path.
`--rig` filters by target rig name and `--since` by install time. Installs through `rig bundle install`,
`rig bootstrap <archive>` and `rig up <link>` are recorded; `rig up <local archive>` isn't.
---
## Assembly Process
When `rig bundle create` runs, the `PodBundleAssembler`performs these steps:
When `rig bundle create` runs, the `PodBundleAssembler`stages the files (steps 1-4). The create route then adds the
author's `bundle.yaml` contents, the carried packs and project, and the configuration and assembler stamps, and does
steps 5-7:
1. **Parse and validate** the rig spec
2. **Collect rig-level files:**
@@ -305,8 +559,11 @@ When an agent spec has `imports`, each import is resolved, vendored into the bun
5. `rig_spec` is required and must be a safe relative path
6. `agents` must be an array
7. Each agent must have `name`, `path` (safe relative), and `hash`
8. Integrity `algorithm` must be `sha256`
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
8. Optional blocks, when present, must be well formed: `provenance`, `compatibility`, `skills`, `plugins`,
`workflow_specs`, `context_packs`, `agent_images`, and `project` (an `id` made of letters, digits, `.`, `_` and `-`,
and a safe relative `path`), plus `included_login_files` (unique safe relative paths)
The manifest validator doesn't check `integrity`; unpack does (below).
### Archive safety (enforced on unpack)
@@ -314,7 +571,7 @@ When an agent spec has `imports`, each import is resolved, vendored into the bun
2. No absolute paths
3. No `..` path traversal
4. Archive digest must match sibling `.sha256`
5. Per-file content hashes must match integrity section
5. The manifest must have an integrity section, and per-file content hashes must match it
### Assembly validation
@@ -322,6 +579,10 @@ When an agent spec has `imports`, each import is resolved, vendored into the bun
2. All `agent_ref` paths must resolve to valid agent specs
3. All declared `docs` files must exist on disk (missing docs fail assembly)
4. Culture file and startup files are collected best-effort (missing = skipped)
5. A declared skill that's missing or unreadable is a warning, written into the manifest's provenance notes; it
doesn't stop the build
6. Sensitive paths ("Sensitive paths detected in bundle") and content that can't be shipped publicly ("Public artifact
substance refusal") stop the build
---
@@ -336,6 +597,9 @@ Key differences from v2:
- No `import_entries` in package entries
- Legacy rig spec format (flat nodes, not pods)
The v1 validator checks a `project` block the same way as v2, so a v1 bundle can't write outside the projects root or
Coverage: openrig.package-digest/v1; packaged files only; excludes bundle.yaml, ignored junk and file modes; not host-resolved resources or execution.
Assembler: OpenRig 0.6.6
Archive SHA-256: a1b2c3d4...
Digest valid: true
Integrity: PASS
What this bundle declares before installation:
...
```
### Install and launch
@@ -380,4 +651,6 @@ Equivalent explicit form:
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
```
The bundle is extracted to a temporary directory and its integrity is verified. A schema-version-2 bundle is then copied into the target root (refused if the target holds different files at the same paths) and the rig is launched from there, with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
The bundle is extracted to a temporary directory and its integrity is verified. A schema-version-2 bundle is then copied into the target root and the rig is launched from there, with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
A running team must be stopped before reinstalling. Reinstalling into a stopped team's recorded install folder preserves conflicting files in the instance's `bundle-backups` directory; the result names the backup. The folder must still contain that bundle's installed manifest. A copied same-name manifest in another folder does not authorize replacing its conflicting files. Other targets retain first-install behavior: different files at the same paths are refused before writing. New installations record the canonical target folder for each generation, independently of `--cwd`. Older generations and new generations created directly from `rig.yaml` have no recorded folder and retain the manifest-only check until their next successful bundle installation, so a copied same-name manifest can still qualify for those generations.
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
---
For choosing IDs, see [naming rigs, pods and seats](topology-naming.md). Keep the same
logical names in edges, startup instructions, workflow targets and runtime presets.
## Minimal Valid Example
```yaml
@@ -18,7 +21,7 @@ pods:
- id: dev
label: Development
members:
- id: impl
- id: build
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
@@ -105,7 +108,7 @@ pods:
required: true
actions: []
members:
- id: impl
- id: build
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
@@ -121,7 +124,7 @@ pods:
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load the implementation-pair skill and begin."
value: "Read your guidance and begin."
phase: after_ready
idempotent: true
- id: qa
@@ -131,7 +134,7 @@ pods:
cwd: "."
edges:
- kind: delegates_to
from: impl
from: build
to: qa
- id: rev
@@ -152,13 +155,13 @@ pods:
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
to: dev.build
- kind: delegates_to
from: orch.peer
to: dev.qa
- kind: can_observe
from: rev.r1
to: dev.impl
to: dev.build
- kind: can_observe
from: rev.r2
to: dev.qa
@@ -170,15 +173,18 @@ edges:
| Field | Type | Required | Default | Description |
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
| `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. |
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
| `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). |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute). Absent leaves the default floor. A member may set its own `permission_policy`, which takes precedence over the rig-level one. See "Attaching a permission policy" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex members always use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `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. |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, `rig up` boots them before any agent launches; `rig import` doesn't boot them. |
| `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. |
| `builtin:standard` | config | everything not listed, including `push_to_remote` (`default_posture: allow`) | `create_pr`, `publish_package`, `merge_or_release`, `force_push`, and the destructive class | nothing |
| `builtin:open` | config | everything, including PRs, publishing, merges and force pushes (`default_posture: allow`) | the destructive class only | nothing |
| `builtin:yolo` | flag (`launch_posture: full_bypass`) | everything; the runtime's permission prompts are bypassed at launch | nothing | nothing |
| `builtin:auto` | flag (`launch_posture: auto`) | Claude runs with `--permission-mode auto`; Codex, Pi and OMP launch at the floor | Claude: decided by auto mode; others: as at the floor | Claude: decided by auto mode; others: as at the floor |
The destructive class is `delete_everything`, `drop_persistent_store` and
`reset_or_discard_vcs`; `builtin:locked`'s list also names `delete_files` and `force_push`.
**At launch.** `builtin:yolo` selects Claude `--dangerously-skip-permissions`, Codex
`-s danger-full-access -a never`, and Pi `--approve`. `builtin:auto` selects Claude
`--permission-mode auto`, while Codex and Pi do not have an auto mode and launch at the floor.
Every other seat launches at the floor (a non-kernel seat with no policy also gets the
- Codex `-s workspace-write`, or `-p <profile>` when the member sets
`codex_config_profile`, in which case the profile governs its own sandbox;
- Pi `--no-approve` by default.
Seats of the rig named `kernel` are the exception. With no member or rig policy, no
per-seat choice and (for Codex) no named profile, Claude launches in `acceptEdits` with a
per-launch `--settings` allow list for its file tools and operational commands, and Codex
launches with `-s danger-full-access -a never`, plus per-launch `-c` overrides that hide its
full-access and GPT-5.1 migration notices, whatever the rig's non-interruptive choice.
**Config-surface policies are recorded, not applied at launch.** The seat still starts at
the floor. The `allow`, `ask` and `deny` rules take effect once they are translated into the
runtime's native settings. The `applying-a-permission-policy` skill in `openrig-core` does
that translation, with per-runtime limits. For example, Claude's prefix rules can't
reliably tell `git push --force` from `git push`. An `ask` waits for a person, so it pauses
an autonomous seat. Standard suits interactive work, and the policy files point
autonomous teams to Open or YOLO.
**Pi has no permission policy.** `--approve` and `--no-approve` set Pi's resource trust. The
OMP variant maps that trust to its approval mode: `yolo` under `--approve`, otherwise
`always-ask`.
**A custom policy file** is Markdown with frontmatter: `policy_schema_version: 1`, `name`,
`source: custom`, `description`, and `surface`.
- `surface: flag` adds `launch_posture` (`floor`, `full_bypass` or `auto`).
- `surface: config` adds `default_posture` (`allow`, `ask` or `deny`) and the `allow`, `ask`,
`deny` and `destructive_class` lists (`[]` for none).
**Claude Code's own checks.** The first time Claude Code starts an interactive session
with permissions bypassed, it shows a warning dialog asking you to accept responsibility;
declining exits. OpenRig reports such a seat as `attention_required` and holds its startup
context for `rig seat continue`; `rig up --non-interruptive` or `rig bundle install
--non-interruptive` accepts the warning with a launch flag instead (see
[non-interruptive mode](non-interruptive-mode.md)). That choice is saved on the rig, not in
`rig.yaml`. On Linux and macOS Claude Code refuses that mode when run as root or under `sudo`,
except inside a recognized sandbox. See Claude Code's
[Choose a permission mode](https://code.claude.com/docs/en/permission-modes#skip-all-checks-with-bypasspermissions-mode).
### Launch environment
`env:` sets environment variables for the seats of a rig, so a team can share a setting such as a feature flag, a region or a log level without every member hand-editing a shell profile. Declare it on the rig, on a member, or both:
```yaml
env:
LOG_LEVEL: info
REGION: eu
pods:
- id: dev
members:
- id: impl
runtime: claude-code
env:
LOG_LEVEL: debug # overrides the rig's value for this seat only
```
- 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.
- 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.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
- Spec values are stored in OpenRig's database and in rig snapshots, and `rig export` and bundles copy them, so a secret put there by mistake travels with the rig.
- While a seat launches, the values are passed to tmux as command arguments, so other users and processes on the same machine can see them for that time. They then sit in the seat's process environment. Only the managed Claude launch command is value-free: it forwards names, and the terminal supplies the values.
- A one-off `--env` value is not stored in the database or in snapshots, but it reaches tmux the same way, so it is visible in the same ways while that launch runs.
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
### Choosing the Claude instruction file
OpenRig writes its instructions for Claude Code members into managed blocks in
@@ -247,17 +409,18 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| Field | Type | Required | Default | Description |
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `id` | string | yes | — | Pod identifier. Must not contain dots or `@`. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
| `summary` | string | no | — | Pod description. |
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy, validated and stored with the pod (see "Continuity Policy"). |
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
| `members` | Member[] | yes | — | The pod's members. It must be an array; validation doesn't refuse an empty one. |
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
### Pod ID Rules
- Must not contain dots (`.`)
- Must not contain `@`, which separates the pod/member portion from the rig name in session addresses
- Must be unique across all pods in the rig
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
@@ -268,17 +431,44 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| Field | Type | Required | Default | Description |
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
| `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. |
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
| `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). |
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
| `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. |
| `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. |
| `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). |
| `mechanic` | string | no | — | A canonical `seat@rig` session address, for `apprentice-handover`. |
| `session_source` | object | no | — | Start the seat from an existing session: `fork` with `native_id`, `rebuild` with `artifact_set`, or `agent_image` with `image_name`. A Claude Code `fork` needs Claude Code 2.0.73 or later. Not valid on terminal members. |
| `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. |
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
- **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.
### Oh My Pi (`runtime: omp`)
`runtime: omp` launches Oh My Pi through OpenRig's RPC runner. It is separate from `runtime: pi`; OMP does not use Pi's `--name` or `--approve` flags.
- **State:** Each seat uses `$OPENRIG_HOME/state/omp/<seat>/agent` and `sessions/` instead of your default `~/.omp` profile, and runs with that seat directory as `HOME`. OpenRig does not copy OMP credentials. The runner finds the real `omp` binary before switching `HOME`, so a version-manager shim such as mise on the daemon's `PATH` still works.
- **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.
### Terminal Nodes
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
@@ -291,6 +481,8 @@ profile: none
All three must be present together. Any partial combination is a validation error.
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.
### agent_ref Rules
- Must start with `local:` or `path:`
@@ -307,7 +499,10 @@ The canonical session name is derived from the pod ID, member ID, and rig name:
{podId}-{memberId}@{rigName}
```
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
Example: pod `dev`, member `build`, rig `my-team` → session `dev-build@my-team`
Pod and member IDs cannot contain `@` because the first `@` separates their portion
of the session address from the rig name. Rig names may still contain `@`.
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
@@ -320,7 +515,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| Kind | Meaning | Use When |
|------|---------|----------|
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
| `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 |
@@ -333,13 +528,13 @@ Edges within a pod use **unqualified member IDs** (just the member `id`, not `po
pods:
- id: dev
members:
- id: impl
- id: build
# ...
- id: qa
# ...
edges:
- kind: delegates_to
from: impl # NOT dev.impl
from: build # NOT dev.build
to: qa # NOT dev.qa
```
@@ -353,7 +548,7 @@ Edges between pods use **fully-qualified `pod.member` IDs**:
edges:
- kind: delegates_to
from: orch.lead # pod.member format
to: dev.impl # pod.member format
to: dev.build # pod.member format
```
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.
@@ -362,13 +557,14 @@ Cross-pod edges must reference different pods. An edge where both `from` and `to
## Startup Block
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
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.
### Files
| Field | Type | Required | Default | Description |
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
| `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`. |
@@ -378,7 +574,7 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
@@ -431,6 +627,31 @@ the current proof; resume/adoption also preserves existing proof history.
The effective selection is recorded on `node.startup_pending`; actions are
persisted in startup context for restore and fresh relaunch.
The harness readiness window defaults to 30 seconds. On a loaded machine, set
`rig config set runtime.readiness_timeout_seconds 60` to give new seats and
handover successors longer to become interactive. The setting accepts 1–600
seconds, applies to the next launch without a daemon restart, and can also be
set with `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`. It also bounds how long a
restore keeps watching a Claude seat whose resume is still starting, so raising
it for slow fresh launches lengthens that wait too. The wait is about the
setting in total per seat, counting the usual 3.2-second check: the adapter polls
for the rest of it (nominally: it counts polls, and each poll's tmux and process
reads add a little). A restore
starts seats one after another, so each seat that never starts Claude adds up to
about this much, minus the 3.2 seconds, to the restore; the time adds up seat by
seat, and a longer setting means a longer wait before a stuck seat is reported.
A seat that is still not interactive then ends `attention_required` with its
launch and resume token kept, and the later readiness wait is skipped for it.
That holds at every setting: at 1–3 seconds there are no extra polls, but an
unresolved boot is still kept rather than failed. A pane that shows a Codex or
another conversation's Claude is not waited for. `rig up` and
`rig launch` wait 120 seconds for the daemon, so a restore of several stuck
seats can pass that (in one measurement, about 31 seconds a seat at the
default, 95 for three). A client timeout and exit code 1 do not mean the
restore was rolled back or that it should be run again; check the real state
first with `rig ps`. Apart from this, the setting does not change the time
spent by the runtime adapter before readiness polling starts.
---
## Services Block
@@ -441,7 +662,7 @@ The services block is optional. When present, services boot before any agent nod
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
| `project_name` | string | no | stored predecessor project or unique rig-ID default | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. An explicit value takes precedence. A new rig uses a stable, unique rig-ID-derived name. A same-name replacement inherits the stored project of the exact generations it replaces; conflicting predecessor projects require an explicit value. Existing stored names remain unchanged. |
| `profiles` | string[] | no | — | Compose profiles to activate. |
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
@@ -454,7 +675,8 @@ Each target must define exactly one of `service`, `url`, or `tcp`:
```yaml
wait_for:
# HTTP probe — hits the URL, expects 2xx
# HTTP probe — any 2xx or 3xx response passes the HTTP wait target; redirects are not followed.
# This proves the server answered, not application readiness.
- url: http://127.0.0.1:8200/v1/sys/health
# TCP probe — connects to host:port
@@ -484,7 +706,7 @@ surfaces:
command: "vault status -address=http://127.0.0.1:8200"
```
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
Surfaces are metadata only. They are displayed in the UI and in `rig env status --json` output (the text output lists only services) but are NOT executed by OpenRig.
### Checkpoint Hooks
@@ -498,10 +720,10 @@ checkpoints:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | yes | Unique identifier for this checkpoint. |
| `export` | string | yes | Shell command to export state.`{{artifacts_dir}}` is replaced with a daemon-managed path. |
| `import` | string | no | Shell command to import state on restore. |
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
Checkpoint hooks are validated and stored with the spec. In this version the daemon doesn't run them during snapshot or restore, and doesn't substitute `{{artifacts_dir}}`.
---
@@ -532,11 +754,24 @@ continuity_policy:
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
These fields are validated, stored on the pod and written back on export. In this version nothing acts on
`sync_triggers`, `artifacts` or `restore_protocol`.
---
## Validation Rules Summary
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
Unknown keys at the rig, pod, member and edge levels are refused ("unknown key … refusing the spec because
normalization would otherwise discard it"). Keys inside `startup`, `services`, `continuity_policy` and `workspace` are
not checked this way. Some rules are enforced at preflight rather than validation: the runtime must be one of the
supported values, rig, pod and member names may use only letters, digits, `-`, `_`, `.` and `@` where allowed above,
each `agent_ref` must have an `agent.yaml`, Codex profiles must load, and a cwd may not be inside the OpenRig
installation.
Schema validation checks the structural rules below. Pod and member IDs containing
`@` are additionally rejected during preflight and member creation or launch; a
spec can pass `rig spec validate` and then fail at preflight or import. Run
`rig spec preflight` before creation or import.
1. `version` and `name` are required non-empty strings.
2. `pods` must be a non-empty array.
@@ -573,8 +808,13 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
"description":"Derived from the archive's files alone, for one configuration and one assembler version; kept outside the archive, so it never feeds the package digest. A view, never a gate; carries no tested status. Consumers ignore unknown properties; producers add only optional properties within v1. An empty list means none are known; something unknown goes in unknownBeforeLaunch.",
"$defs":{
"sourceRef":{
"type":"object",
"required":[
"path"
],
"properties":{
"path":{
"type":"string",
"minLength":1
},
"field":{
"type":"string"
}
},
"description":"Archive-relative file, and optionally the field within it."
}
},
"type":"object",
"required":[
"schema",
"state",
"identity"
],
"properties":{
"schema":{
"const":"openrig.bundle-behaviour/v1"
},
"state":{
"enum":[
"generated",
"not_generated"
]
},
"reason":{
"type":"string"
},
"localInspectCommand":{
"type":"string",
"description":"For not_generated: the existing command that shows this view locally, for example rig bundle inspect <archive> --json."
"description":"Declared in the archive, the product's conditional default, or not resolvable before launch."
},
"nativeEffect":{
"const":"unknown",
"description":"Host settings (for example OPENRIG_YOLO, persisted or native settings) decide the effective permission at launch."
},
"permissionPrompts":{
"enum":[
"off",
"on",
"default"
],
"description":"Archive declaration per seat: off for full_bypass (including builtin:yolo) on any runtime; on for a Claude/Codex flag-surface floor policy, or Codex under auto; default for Claude/Codex with no declared policy. Omitted for Claude under auto, config-surface policies, host-resolved Codex profiles, and Pi except declared full bypass. Absent means not stated, never on. Host settings may differ; nativeEffect remains unknown."
},
"sourceRefs":{
"type":"array",
"items":{
"$ref":"#/$defs/sourceRef"
}
},
"firstRunWarnings":{
"type":"object",
"properties":{
"claudeBypass":{
"const":"harness_asks_once"
}
},
"description":"Declared full-bypass Claude seat: the harness asks for bypass-warning acceptance if it has not been remembered. No host acceptance state is read; nativeEffect stays unknown."
},
"nonInterruptive":{
"const":"available",
"description":"This declared full-bypass Claude/Codex seat can use --non-interruptive launch flags. Availability is not the operator choice or proof that no prompt will appear. Omitted for other postures, unresolved policies and runtimes with no such flags."
},
"nonInterruptiveDefault":{
"type":"boolean",
"description":"Optional authored rig-level default for supported full-bypass Claude/Codex launches. An explicit launch option overrides it; absent is not an off or on claim. Native effect remains unknown."
"description":"Optional opt-in service declarations from archived Compose YAML. Host-resolved values remain unresolved; no Compose or health probe ran.",
"description":"Every member's pod.member=runtime, sorted by pod.member in code-unit order, joined with ','."
},
"packageDigest":{
"type":"object",
"description":"SHA-256 over UTF-8 lines JSON.stringify(path) + '\\t' + sha256 + '\\n', one per integrity.files entry, sorted by path in code-unit order. See docs/reference/bundle-formats.md.",
"title":"Public status file (generated; do not edit)",
"description":"Built from run records by rule openrig.status-rule/v1, from an allowlist of output fields: no private paths, host names, row IDs, account names or receipt text. Regenerating gives identical bytes. An empty platforms object means Not tested. Closed on purpose: no property outside this schema may appear, so a private field can't leak in.",
"type":"object",
"required":[
"generated",
"listings",
"harnessChecks",
"bodyDigest"
],
"additionalProperties":false,
"properties":{
"generated":{
"type":"object",
"required":[
"by",
"notice",
"rule",
"generatorVersion"
],
"properties":{
"by":{
"type":"string"
},
"notice":{
"const":"Generated file. Do not edit by hand; regenerate it."
"publicNote":"Pi was not signed in on the test machine."
},
"relations":{
"supersedes":[],
"withdraws":[],
"resolves":[]
}
}
Loaded 100 of 2056 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.