In routes/activity.ts, a claude-code identity report whose transcriptPath key is present but doesn't name <session id>.jsonl returns ok with tokenPersisted false and transcript_not_this_session, so a Codex helper that inherited a Claude seat's environment no longer replaces the seat's resume token with its thread id or publishes it as the seat's identity. The seat's own reports, in either path spelling, still move the token; a real Codex seat is untouched; a relay that sends no transcript field writes as today. The rule follows the producer behaviour inspected in Claude Code 2.1.282. Reported by pr0xc3nt4ur1, with the evidence table and the set-resume-token workaround.
Migration 154 adds a nullable root_objective column to workflow_instances. WorkflowRuntime.instantiate, which instantiate-lifecycle and run reach, stores the objective, and rowToInstance returns it as rootObjective, so list, show and trace carry it with no reader changes; status rows built from list carry it too. show, trace and status print an objective line only when one is recorded, so an older instance, which reads null, renders as before. No back-fill: older objectives stay in their entry packets. Reported and traced by billbenetti.
mcp-server.ts adds person-scoped question tools for a grant carrying question read and answer and person.entity_id, which mcp-http.ts checks against the daemon's entity pattern; only rig_question_answer's mission-control call uses a client acting as <person>@external. A question is offered and answered only while it sits in that person's Steering view with an answer verb and the view's perspective is exactly that person, and a row read afterwards is re-checked against the attention item's verb and recipient, so a moved park is dropped. Each step posts with its own operationId and expectedRevision; a retry reconciles by operation identity and, once it confirms an earlier step, stops and reports recorded and notRecorded instead of resuming, so it never overwrites a newer answer. An identical retry on a settled row confirms through audit reconciliation; thrown or 5xx sends report the confirmed prefix and the indeterminate step. With nobody registered the tools refuse (person_not_registered); a malformed person withholds only the person tools. hosted-members.md and the openrig-user skill describe it, and the skill's tool counts are corrected to 39 and 20.
daemon-lifecycle.ts removes HC_SCOPE, HC_TERMINAL_ID and HC_TERMINAL_SOCKET from the environment it gives the daemon process, before that process exists, and parent-session-env.ts adds the same three names to the parent-session variables kept out of seats and the tmux server, while a seat's explicitly declared env stays. Inherited herdr-core terminal scope could make the detached daemon and seat processes eligible for herdr-core's cleanup of the launching terminal. Existing fallback paths still warn and continue. Tests cover the CLI start, the parent-session scrub, the seat integration and the tmux credential environment.
public-what-you-can-do.md and docs/reference/agent-spec.md take the release-only changes from 14d89431, cd8e32f1 and f90481bc by a three-way merge against their merge-base with main (d539277a), with no conflict and neither file overwritten: the capability-delta-v0.7.10 marker and 92 top-level verbs counted from main's built program, rig exec and its qualified adapters, permission observations with their sensitive and in-memory limits, agent-spec's scalar-to-list rule, tmux-mode delivery, managed compaction's held step, and Codex non-login shells. Main's later #2698, #2699 and #2626 guidance stays.
closeCrossHostHandoffSource takes the source's claim as the route read it before forwarding. If the row was claimed since, it still closes, records the overridden claim in the transition note, and stages one claim-overridden wake to the claimer in the close's transaction, naming the destination and the successor's plain id. That wake is current only while its row stays closed, owned by the claimer, and its latest close is the notice's own transition. Startup cleanup skips it, the recovery drain selects it on its closed row, and the prompt-clear retry finds such rows in one pass from the notices' id range on the outbox key, with ordinary wakes still pending-only. A full held-message quota records the wake as not retained without undoing the close. Refs #2631 (item 2); the opt-in --if-state pending is not included.
The working-with-an-openrig-team skill's question step, in all three copies, and the front-door instructions in mcp-server.ts say to record exactly what the person typed in resolution, with no label, quotation marks or words of the agent's own. FRONT_DOOR_WORKFLOW_VERSION moves from front-door/v1 to front-door/v2; it is used only in the instructions text and its test. The skill-edge digests are regenerated, and the front-door test requires the new wording and refuses the old. No behaviour change.
packages/tui/src/demo-data.ts lines 211-212: the two demo stream events' sourceSession values become dev50-guard@openrig-build and dev50-planner@openrig-build, and the second body reads "provider re-auth completed on remote-host", the snapshot's own demo host. The pinned "provider re-auth completed" text is kept.
Root .claude-plugin/marketplace.json and .cursor-plugin/marketplace.json list the openrig plugin at its subpath (packages/daemon/assets/plugins/openrig). The plugin's .codex-plugin/plugin.json gains a logo, composerIcon, category, Read/Write capabilities and three default prompts, its .cursor-plugin/plugin.json gains a logo, and assets/logo.svg is a square mark drawn white on a filled dark square. The plugin version test covers the manifests. This is listing source only; no marketplace submission.
The openrig plugin README's "No seat" section describes starting the app from a shell as working only for that launch, and its server note becomes the steps to give the app a seat: run your own rig mcp serve --front-door server with OPENRIG_SESSION_NAME set and turn the plugin's server off (in VS Code under another name, disabled in MCP: List Servers, since two servers named openrig stop each other; in Cursor, disable openrig · Plugin), with Copilot CLI marked unverified. rig_whoami's seatless message in mcp-server.ts says the same in one sentence, and the working-with-an-openrig-team skill's "Another app" step says it in all three copies, with the skill-edge digests regenerated. No behaviour changes.
scripts/pack-release.mjs adds npm run pack:release -- <dir> for releases that ship @openrig/acp beside @openrig/cli. After a successful build:package it checks that the launcher's version, its exact @openrig/cli pin, and the registry listing's version and npx package all match the CLI's version, naming every mismatched file and field and writing nothing on a mismatch or when packages/cli has no daemon/dist. It packs the CLI with the producer's existing npm pack --workspace packages/cli --ignore-scripts --json form and the launcher by path, then writes RELEASE-ARTIFACTS.json with each archive's path, version, size, sha256 and integrity, the listing's agent.json and icon.svg hashes, and the publish order (CLI, launcher, listing). It publishes nothing; its output says to publish the recorded archive files themselves by path, since publishing from packages/cli reruns prepublishOnly. A comment notes npm 10 still runs a prepare script under --ignore-scripts; neither package has one. build-package.sh's last line names the step.
rig queue whoami's route (routes/queue.ts) already reads the project catalogue for per-row missions roots; it now also returns the healthy catalogued project roots. The openrig-core refocus hook passes them through its existing cached whoami answer to trace-to-root.py as extra work roots, a refocus-only input, so OPENRIG_WORKSPACE_ROOT and the CLI's config are untouched. The script ascends from the node to the deepest known root that contains it: a catalogued project's root, inside or outside workspace.root, or workspace.root itself. A node outside every known root is still reported as a gap, and an unavailable catalogue gives no extra roots. The refocusing skill documents it, and the skill-edge digests are regenerated. No new command, refusal or error code.
In claude-compaction-enforcer.ts the replacement attempt records the ended attempt's own conversation as afterEndedSessionId (null when it read unknown) beside afterEnded, and recoverAfterRestart rebuilds the gate from it in both unsent-replacement paths, so a restart matches the in-memory result; a record from an older daemon reads it as unknown, which keeps the gate, and the field is dropped with afterEnded and not shown by rig compact --state. drainPendingPreparedCompact now checks for another known conversation of the same seat before returning on an incomplete map, so a stale sample of another conversation ends an automatic attempt whose request went out instead of leaving its map to be sent there later. One visible change: with an invalid policy, an automatic attempt still waiting on its map reads invalid_policy rather than no_pending_stage. Follow-up to #2682 and #2698.
The README, docs/reference/acp-agents.md, the sign-in method's description in agent-server.ts and the serve help in commands/acp.ts say rig acp serve uses rig's daemon lookup, local by default or the daemon OPENRIG_URL names, where they had said it talks only to the local daemon. The sign-in method keeps its name, "OpenRig on this machine", qualified by its description. Two code comments drop "local". No code path changes.
skill-catalog.ts gives gitState's four synchronous execFileSync Git reads (rev-parse --show-toplevel, status, rev-parse HEAD and the committed SKILL.md read) a five-second timeout each, as the daemon's other synchronous Git sites already bound their own reads. A timed-out discovery shows the existing unreadable-repository error, and other failures keep their existing handling and the committed-name fallback. The three projection-ignore Git calls are outside this change, and it is not an overall catalog deadline or a throughput change. Tests cover real local Git stalls on each read. Refs #161 (item 4).
In agent-server.ts one helper, unanswered(), handles every way a shown question ends unanswered here: dismissed, answered with a value that isn't one of its choices, the turn cancelled, the work closed first, or a form request the app couldn't show. It writes the card's tool_call_update as before and one agent_message_chunk naming the seat, the question and its row. outstanding becomes a Map from tool call to the question's name so finishQuestions can name each one. Nothing is recorded as an answer, and no further question opens after a cancel. docs/reference/acp-agents.md says so. A follow-up to #2708.
Rebuild handover delivery of the priming packet now goes through the same submission check as the fresh restore packet (confirmPacketSubmission, which reads the Claude composer after submit and retries once). A packet still observed staged after that retry, or a composer that can't be read, returns unconfirmed instead of failing, so the ordinary continuity and commit path binds the running successor while the result carries restorePacket.status unconfirmed with its reason and the startup context is not marked delivered. A failed paste or submit still fails with context_delivery_failed; its guidance now says the discovery candidate was marked vanished, the successor may still process the packet, and its state should be checked before another attempt. rig seat handover's rebuild output names the priming packet as unconfirmed, heads the artifact list "Rebuild packet includes", and for a still-staged packet says to submit the existing packet once only if it is still staged, never to resend blindly or rerun the handover. Tests add a fresh and rebuild matrix over clear, staged, unreadable, edited and unrelated screens and the CLI's rebuild wording for both unconfirmed reasons.
SeatAttentionReconciler.onSeatActivity runs the existing reconcileNodeRuntimeTruth once per change into running or idle, for a seat whose full-restore outcome still needs attention (not reconciled, not fenced by a later ready fresh launch, not an assisted startup), and onRestoreCompleted checks each of the rig's seats last read running or idle once when a full restore completes; startup.ts wires both from the existing activity reading and the restore.completed event. The check itself is unchanged in its proof: a live tmux session, exact native resume-token lineage and a usable pane. A failure writes no reconciliation and emits no refusal. Before appending a pass, reconcileNodeRuntimeTruth re-reads the latest restore attempt, the binding's session and the expected resume token, and returns proof_superseded without a receipt if any changed, which also covers rig seat clear-attention; two overlapping checks for one attempt append one receipt. getDerivedAttentionOutcome reads restore outcomes and starts through their own indexes instead of every event in the rig. The startup guide and the state taxonomy describe the automatic reconcile, with clear-attention as its on-demand form. For #2622.
In claude-compaction-enforcer.ts, an automatic attempt whose map is ready but whose seat is still busy at the deadline is held, keeping its map, until an idle window lets /compact go, instead of stopping and re-preparing. The hold ends when a fresh sample dips below the threshold (a dip before the hold is remembered even if usage climbs back), when usage grows PREPARED_MAP_MAX_GROWTH_PERCENT (15) points past the delivered map, after PREPARED_MAP_MAX_AGE_MS (2 h), when reconcilePreparations finds the map or its marker gone (preparation_incomplete), when a known conversation differs from the attempt's on the fresh path or at drainPendingPreparedCompact (an armed map is stopped too), or on a new occupant generation; a /compact already in flight is left alone. stopPreparation records an ended attempt whose request was delivered, and endedAttemptGate then answers preparation_stopped during the cooldown and, after it, lets a replacement through only when the actual send finds the seat idle; the gate survives a stale observation and a restart within the recovery window (default 6 h). ContextUsageStore keeps an unknown sample's conversation and session name, and waitUntilIdle gains a single-observation option for the gate. New stop reasons are below_threshold_while_prepared and prepared_map_stale; prepared_window_closed is gone. The CLI help and the context pack name the ends and the 6 h bound. For #2682.
In mcp-server.ts, the seatless rig_whoami message no longer tells the agent to set OPENRIG_SESSION_NAME in its environment or where the server is configured, which a plugin's server in VS Code, Copilot CLI or Cursor can't take. It names each app's documented route instead: the plugin's seat setting in Claude Code, --env on codex mcp add for Codex, and for VS Code, Copilot CLI and Cursor either starting the app from a shell with the variable set or running your own rig mcp serve --front-door server. seat_required now points the agent to rig_whoami for those steps. One test cell asserts the wording; nothing else changes.
seat-activity-service.ts gains startSeatPollers, which starts the activity sampler and the structural-activity poller at one interval read once at daemon start: OPENRIG_SEAT_POLL_INTERVAL_MS when set and valid, otherwise the typed daemon.seat_poll_interval_ms setting, otherwise the existing 1000 ms. Values run from 1000 to 2000 ms; an invalid environment value is dropped with a store warning rather than clamped. index.ts calls it in place of the two direct starts. The setting is registered in the daemon's settings store and the CLI's config store and config command, and the settings key count moves to 100 alongside retention.events_days. tmux-servers.md documents the trade: fewer spawned tmux clients per second against slower activity readings. Tests cover the precedence, the bounds, the invalid value and that both pollers share the interval. For #2687.
In outbox-handler.ts, a held message from a sender to a seat retires that sender's earlier held messages there into itself (retired_by outbox@system, retirement_reason "coalesced into <id>"), and the newest carries coalesced { count, outboxIds } on rig seat held-messages and --id, repointed on each later coalesce; "newest" is by dispatch time, then insertion order. A late-settling group folds only its own members into the newest row, so a newer multipart send's members stay held together. Lookups and repoints require outbox@system, the same sender and the same seat, so a hand retirement with the same reason text is never treated as a link. draft-aware-delivery.ts coalesces only after an attempt settles as retained, and queue-repository.ts routes a draft-aware wake's capacity check through the same per-sender rule. An urgent marking carries forward within one sender's own messages, for display only. seat-delivery.md describes the rule, says the sender's next held message clears an older backlog, and names rig seat retire-held-message for immediate relief. For #2430's held-message part.
In agent-server.ts, an app whose initialize advertises clientCapabilities.elicitation.form gets each team question as ACP elicitation/create in form mode: one string field whose enum lists each choice by its label (with "(recommended)" where marked, and the option id added when labels repeat), because the released T3 Code reads choices only from enum. The answer maps back to the option by that exact value; decline, cancel or any other value records nothing and the question stays open. Other apps keep session/request_permission with one allow_once option per choice. When the prompt holds exactly one <user_request> opening and closing mark, those words become the row's summary and the session/list title; otherwise the prompt's first line stays, as before, and the row body is always the full prompt. commands/acp.ts, the README and the guide say to register a person first (the only registered person is the default), pick one with rig gateway default when there are several, or pass --as, and name Zed as the tested example.
The stuck sweep reads recovery findings and custody rows' linked successors through batched lookups (createRecoveryLookup, createLinkedSuccessorLookup) with the same predicates as the per-row queries, lent to synchronous reads and dropped after every yield or awaited write. runStuckSweep yields the event loop with setImmediate every STUCK_SWEEP_YIELD_EVERY_ROWS (50) rows. Each finding kind is a rule for one row: right before a finding is created its rule runs again on the row as it is now, without the lookups, and the finding takes that run's route, reason, targets and escalation note and hop; a row that no longer qualifies gets none. Before the resolution closes a rule-resolved finding for a row this pass did not find, the rule runs again on that row, so a row that came back during the pass keeps its finding. findOverdue and findUndelivered take a qitemId so a listed row can be re-read by id. The scheduler runs one pass at a time: a tick during a pass is skipped, logged with the running pass's start, and reported as runningSince and skippedTicks in the sweep's status. Tests add a batch suite, scheduler cases, sweep cases and a cross-rig escalation case.
retention.events_days (OPENRIG_RETENTION_EVENTS_DAYS; daemon, CLI and TUI settings) defaults to 0, which keeps every event; a positive day count turns on pruning of eight history-only types: view.changed, snapshot.created, watchdog.evaluation_fired, queue.created, queue.updated, queue.claimed, queue.unclaimed and queue.handed_off. The existing retention sweep, at boot and daily, runs pruneEvents: it walks from a stored low-water mark to the row before the newest in bounded windows, checks each row's own age against the cutoff (so out-of-order timestamps can't strand older rows), yields and pauses between windows, and deletes only the listed types older than the cutoff. A capped pass stores resume_seq so the next sweep continues where it stopped, and the mark never passes a young row of a listed type. Migration 153_events_retention_marks adds the one-row marks table. The help and whats-new.md say what pruning gives up (replay from /api/events past the cutoff, the historical telemetry export, older log and feed entries) and that a new value is read at the next sweep. The other retention jobs are unchanged. For #2697.
In tmux-control.ts, the control client records each server version it reads: a version that rules control mode out is logged once, naming the version, that the running server decides rather than the binary, and the guide's address; an eligible version reads "allowed", and "Control mode is on" is logged at the first successful attach per version, with the status reading "on" only while a client is attached. A new GET /api/adapters/tmux/control answers that state from memory with no tmux call, and rig doctor's new tmux_control_mode check warns when control mode is asked for and off, warns when it is allowed but no client is connected, warns when the shell sets the variable but the daemon wasn't started with it, passes only when on, and otherwise skips, including against an older daemon. docs/reference/tmux-servers.md gains "Moving an existing team onto tmux control mode", which joins the reference pack so the address in the log and in doctor resolves. Which servers get control mode is unchanged. Refs #161.
In mcp.ts and mcp-server.ts, a seat that is empty, blank or a literal unfilled template such as ${user_config.seat} is treated as no seat, so the server never stamps writes with the placeholder or with an inherited primary or legacy name. With no seat, create and chat declare the sender through the existing declared-actor path as unknown-mcp-<8 hex>, minted once per rig mcp serve process: once per connection over stdio, and once at start for every request's server over HTTP (mcp-http.ts), so a same-id create retry carries the same source. Each such row opens with a one-line note of what is known (the client's own name and version, no seat, a local connection) and says there is no return address; a task's note says to follow it by its task id. Plain sends stay unsigned. rig_whoami names the label. Claim, update, handoff, unclaim and decide still answer actor_required, and owned or mine listing still needs a seat, so a shared label reaches no author-equality check. The working-with-an-openrig-team skill (home, canonical and plugin copies, with digests), the openrig-user skill and the openrig plugin README now say that work goes through as the unknown sender rather than telling an app to stop.
Migration 152 adds workflow_instances.spec_source_path and spec_json, both nullable; at instantiate a run keeps the spec it runs, rig placeholders resolved, and the file it was instantiated from. getForInstance reads the cache row while it still comes from the run's own file, matched by real path, so an edit there reaches the run and a resume re-resolves from it; once another file with the same id and version has replaced the row, the run uses its kept copy. Just before another file replaces a row, the unfinished runs (active, waiting or failed) started from the row's file take the row's spec, in one transaction with the replacement. A file read at a new path with the same bytes after its old path is gone counts as moved, and its runs follow it carrying that spec; any other file leaves them the kept copy. An unrelated byte-identical copy read after the old file is gone also counts as moved. The stuck-overdue exception route resolves the instance's own spec and bound rig, and the slice and reconciliation views read a run's own spec. A lifecycle revision clears the kept copy, and instances from before migration 152 read the cache row as before. The daemon logs each carry or move with the files and the run count. Refs #2607 (the spec-cache overwrite; the {pod} placeholder is separate).
In herdr-follow-view.ts, the strip pane runs printf to clear the screen and scrollback once, then execs tail on the event log, so the login shell's prompt and the echoed command no longer sit above the events and the tail's own errors stay visible. A new canvas records stripConfirmed false and only it gets the 10 s startup window; a strip saved before that field existed gets one process read. In terminal-service.ts, when live following is unsupported and no snapshot can open, the result names the reason: the resolver's error, or incomplete task membership. The CLI help for --follow names Linux and macOS Herdr protocol 22, and terminal-sessions.md describes the uncertain recovery after a lost create reply combined with a startup directory change. Follow-up to #2663; the strip tests cover the cleared start, the legacy single read and the snapshot reason.
An assignee whose result is code can write a JSON manifest of kind openrig.code-result/v1 (repo, base, revision, paths, optional branch and fetch hints) and set it as the item's existing evidenceRef. rig_artifact_read adds codeResult to the file it returns only when the whole text file was read, it names that kind, and base and revision are full commit ids (40 or 64 lowercase hex); a manifest of the kind that doesn't qualify gets codeResultIssue with the reason, so a branch or a short id is never read as a revision. Any other file reads exactly as before, with no new column, store or queue state. rig queue artifact <qitemId> [--root <name>] [--json] does the same read through the same shared roots for CLI clients. The shared reader lives in queue-artifact.ts, and rig_artifact_read's scope rule is unchanged. docs: coordination-primitive.md and cli-reference.md describe the manifest and the verb.
rig acp serve: a cancel that lands while an answer is being recorded stops a multi-question ask before it opens the next question, keeping the answer already chosen; each step's title leads with its row's latest note (lead@team: <note> · Sent to lead@team (qitem-…)), because Zed shows steps collapsed and cuts the end of a title; session/load replays the questions the team asked during each prompt, in the order asked, with "You answered: …", still open, or closed unanswered, read from the session's tagged rows; and an acp.to setting that can't be read comes back as the reason, for the app's sign-in step to show. The openrig-acp launcher starts the @openrig/cli Node resolves for it, never a rig on the PATH, and when that copy's version differs from the pinned one it says so on stderr and runs it. The ACP agents guide and the package README give the full registration command. Tests cover the cancel ordering, the titles, the replay and the launcher's version notice.
In TerminalSessionBroker.ts, TerminalBrokerRegistry.attach treats a torn-down entry as absent: it registers a fresh broker at once and has it wait for the old broker's teardown (stopPipePane and the window-size restore) before it attaches, so the window size is still given back before a new broker pins it. All four teardown paths (a failed open, dispose, session death and the last detach) share one teardown promise, so dispose and session death now await their stopPipePane rather than firing it off, and a late stop can no longer cut a newer broker's pipe. onEmpty checks identity, so an earlier broker's completed teardown never evicts a newer one, and get() skips torn-down brokers. A regression test reattaches during teardown and gets a fresh broker, not a 1011 close.
In topology-events.ts, the shared hub's URL becomes /api/events?replay=100, using MAX_REPLAY_EVENTS (moved above it), which is also how many events the hub keeps for a late subscriber. The daemon route is unchanged: Last-Event-ID takes precedence over replay, so a reconnecting EventSource still gets every event after its cursor, while a new hub after a reload gets the newest 100. Older startup history is no longer streamed to listeners on first connect, so its old activity flashes and edge packets don't replay; current topology still comes from the HTTP baselines. Tests pin the new URL, check that a late subscriber gets the newest 100 of 150, and cover the route's newest-100 replay and its cursor precedence. Related to #2694 and #2695.
In session-transport.ts, inspectComposerInput closes a framed composer on its first aligned rule, as the capture reader already does, and trusts the rows between that rule and the recognized footer only by position: none may be a rule or prompt-shaped, and a framed prompt directly above doesn't count as a composer of its own. findClaudeFooterIndex skips the background task panel only in its own shape (rows of " ◯ " at column 2, with an optional " ● " row above) and only right under a recognized footer. findClaudeComposer and classifyPaneActivity find the closing rule above a statusLine only when climbing from a recognized footer, and their idle window ends at the footer when the panel is recognized and stays clear of the statusLine and panel rows, so a tall panel can't push the status head out of view. readSeatBeforePaste and the startup composerRegion are unchanged. Tests cover both shapes from cropped real captures, the gap and panel variants that must stay unknown, idle classification, and draft-aware delivery end to end. For #2700.
EventBus.publishTransient(event, causeSeq?) delivers an event to the in-memory subscribers without an INSERT, without a seq of its own and without moving the notify watermark; the event carries transient: true and, as its seq, the seq of the stored event that caused it (the newest stored seq when there is none). Every transient goes to one pending queue that the end of each fan-out empties, before the rows subscribers stored meanwhile are drained, so a subscriber sees the cause, then its transients, then the next stored event; outside a fan-out and outside a notify envelope it is delivered before the call returns, and a rolled-back envelope drops only what it queued. ViewProjector.notifyViewChanged publishes view.changed this way (causeSeq is optional), and the view event bridge passes the trigger's seq, so on the view SSE streams a trigger's view.changed frames carry that seq as their id. /api/events skips transient events, so its live stream and its replay agree. The as-built event-system and UI shell docs and the daemon HTTP clients reference say that /api/events carries stored events only, that the view streams are live-only and their id is not a replay cursor, and that view.changed rows stored by older versions still replay and export until they are pruned.
docs/reference/agent-startup-guide.md gains a section, "After a restored seat's prompt": a restore that stops at a prompt, such as Claude's resume selection, records the restore outcome attention_required; answering the prompt or running rig seat continue doesn't change it, so the seat's lifecycle stays attention_required (LIFECYCLE att in rig ps --nodes, needsAttention, the rig's attentionCount and the TUI's attention totals) even once its startup status is ready. After a full restore of a Claude or Codex seat, rig seat clear-attention <seat> checks the live seat (its tmux session is alive and the pane runs the agent resuming the recorded conversation) and then records operator_recovered; a failed check says which check failed, and --reason doesn't bypass it. A wrong recorded conversation can be set first with rig seat set-resume-token; after a partial restore or a community harness's assisted startup, --reason acknowledges the outcome. docs/reference/agent-state-taxonomy.md and the getting-started troubleshooting table say the same. Refs #2622.
packages/daemon/assets/plugins/openrig gains a root plugin.json (the same name, version, description, author and license as the Claude Code manifest) and an mcp.json with one stdio server, openrig, running rig mcp serve --front-door with no environment, in the Agent Plugins 1.0 format that VS Code, Copilot CLI and Cursor read, plus a .cursor-plugin/plugin.json carrying only the name, version and description, without which Cursor ignores the root files and runs the Claude Code entry. check-packaged-plugin-versions.mjs treats the root plugin.json and .cursor-plugin/plugin.json as manifests too, so their versions must agree with the others, with a test cell for each. The README describes the server entries, the install-from-folder steps (VS Code verified on 1.145, Cursor on 3.27, Copilot CLI not yet verified), and what having no seat means: the queue tools that change a task and chatroom send answer actor_required, listing the questions sent to you needs a seat, rig_send sends unsigned, rig_whoami says there is no seat, environment export needs an HTTP work grant, and an app started from a shell with OPENRIG_SESSION_NAME (or RIGGED_SESSION_NAME) set acts as that seat.
rig host pair sends its --timeout (already bounded by resolvePairTimeoutMs) as timeoutMs with POST /api/hosts/pair-request, and the target keeps the request open that long, up to the 32-bit millisecond ceiling; a requester that sends none (an older CLI) gets the 10 minutes it always did. The approval item states the life in the largest whole unit and the UTC expiry to the second, instead of always saying 10 minutes. Expiry is checked against the request's own expiresAt, and an issuance prunes an expired request 50 minutes after it expires (for the default life, the same hour after creation as before), so a waiting requester reads expired rather than pair_unknown. The CLI polls at once, then every 2 s, never sleeping past its deadline. The --timeout help and the CLI reference say the request stays open that long, and that a target older than this CLI keeps it 10 minutes. Refs #2608 (item 1 only).
packages/acp adds @openrig/acp: one executable, bin/openrig-acp.js, which starts the rig acp serve of the @openrig/cli it depends on at exactly the same version, passing on the app's arguments, stdin, stdout, environment and working directory; its own messages go to stderr, it exits with the server's code or signal, and with no @openrig/cli installed it explains on stderr and exits 1. packages/acp/registry/agent.json lists @openrig/acp at that version with no arguments, beside a 16x16 currentColor icon. Because a registry launch has no arguments, rig acp serve without --to reads the new acp.to setting (rig config set acp.to <member@rig>, or OPENRIG_ACP_TO), empty by default and accepting only member@rig; the daemon's settings twin and both pinned key lists carry it. rig acp serve still reads open questions on the pass a cancel wakes but shows none of them, a cancel that lands during that read included. The ACP agents guide names the setting, says sessions are found among the 2,000 newest rows the caller sent, and says a choice is recorded as a click in Slack would be. A lockstep test pins the launcher's version, its @openrig/cli pin and the listing's version to the CLI's. Neither package is published by this change.
Herdr follow views and ensure-viewer accept macOS as well as Linux and check for Herdr protocol 22 instead of exactly version 0.9.5, comparing the selected socket by real path. A viewer keeps the pane and terminal IDs that its successful creation reply returned even if the login shell's rc has already changed directory, so the reported cwd no longer has to match; a lost reply still recovers only through the unique private cwd, and the foreground attach argv and current seat binding are still confirmed before reuse. The attach command is written to an owned 0600 script in the viewer's private directory, hash-checked on reuse and before removal, so the typed input stays short under a canonical-mode login shell, and retiring a viewer removes that script with its directory. A follow view's strip gets a 10-second startup window only until its first start is confirmed, which is then recorded, and the strip pane is re-read afterwards. Where live following is unsupported, the open returns follow_unsupported instead of an error, and a complete task opens as a one-shot snapshot that notes it won't track later owner changes, with its selector retired before the snapshot is dispatched. docs/reference/terminal-sessions.md is updated to match.
verifyPaneIdleShell's default process lister is now readProcessTable, timed under the existing codex.runtime.list_processes site, instead of listNativeProcesses, so the idle-shell proof reads the complete raw table without native executable-path or argv enrichment. An injected lister is still used as given, the proof still needs the same idle shell in two fresh samples, and a failed read still returns no proof. Native identity checks keep the enriched listing. idle-shell-process-sampling.test.ts covers the proof from two fresh tables without enrichment, a failed first or second sample, and the injected lister.
FileEditor in FilesWorkspace.tsx clears its saved indicator from an effect whose cleanup cancels the 2-second timer, instead of an untracked setTimeout after a save, so the timer can't fire after the editor unmounts; files-editor-draft.test.tsx checks that closing the editor cancels it. session-transport.test.ts uses the existing stepped clock for a readiness-transition case and measures the readiness deadline after admission, keeping the original deadlines. The opr-0.4.1.13 table-crash stress case keeps its full workload with a 30-second test ceiling for shared-host contention.
codex-resume-final-native.test.ts runs each case with pollMs 200 and maxWaitMs 200 and a spied sleep, so two loop observations exercise one retry before the gated final read; it asserts three captures and one 200 ms sleep, and keeps real terminal and process observations through the final read. The new codex-resume-final-budget.test.ts checks, for the default budget (26 loop reads), the native fixture's budget (2) and the minimum budget (1), that each of five outcomes (access-token refusal, incompatible client, missing session, ready prompt, occupied shell) is classified only after the loop is exhausted. Outer test timeouts are unchanged, and no product code changes.
prompt-blocked-alert.test.ts: the fallback-routed alert case and the clear-then-return case give their synthetic refusals explicit timestamps around the queue's clear marker and fake only Date for their queue writes, restored in finally, so their outcome no longer depends on wall-clock time catching up with the helper's increments. The fallback case keeps its old and new alert identity and exact wake counts; the adjacent case still checks that one new refusal is not enough, including one that shares the clear marker's millisecond. No production code, sleeps or timeout changes.
Claude Code's self-report status shell is kept as backgroundWork beside the seat's idle-at-prompt activity, never as a new activity value, anchored at the first observation of that shell and not inherited by a new occupant. A claimed row whose owner is idle with a live background shell reads working instead of stalled-after-claim for up to 60 minutes counted from the claim's own anchor, the same one the stall uses, so a shell that never ends or is restarted each turn cannot hold a claim. A row whose owner is owed a managed post-compaction restore reads working for up to 20 minutes from when the daemon began owing it (the enforcer's new postCompactStageOwedSince). A pending input keeps the needs-input reading, and past either ceiling the row reads stalled with the ceiling named in its evidence. The pickup names the grace it is working on, and the stuck sweep leaves an open stalled-after-claim recovery open while its row works only on a grace, so the recovery is still active when the grace expires. The parked-owner reminder skips a seat whose background shell was first seen under 60 minutes ago and names a shell past that ceiling in its evidence. Tests cover each ceiling, the row face and the sweep through a restore's grace and its expiry, and the parked-owner skip.
claude-interactive-isolation.test.ts: the fixture's PROMPT_COMMAND writes the exit status to a sibling temporary file and renames it into place, so the test never reads a receipt that redirection has created but echo hasn't written yet. Before the staged send, the test waits until the pane's command is bash again. The polling helper takes a boolean or a promise of one and keeps the original six-second budget as an elapsed-time deadline. Every existing assertion is kept; no product code changes.
rig workflow unregister [spec] calls the new POST /api/workflow/specs/unregister, which removes the cached spec rows for <name> or <name>@<version>, or with --prune-missing every registered version whose source file is absent (ENOENT or ENOTDIR), optionally scoped to [spec]. It keeps and reports three kinds of row: a version unfinished work is pinned to (retained, readable by name and version and hidden from discovery when its file is gone, as the scanner does), a file still inside the workflows folder or the built-in starters (the next scan would register it again), and a file whose presence couldn't be checked because of an access error. An empty version after @ is a usage error, never every version; naming nothing without --prune-missing, or a version without a name, is a usage error too, and an unmatched spec is spec_not_found. The spec cache gains listRowRefs, cli-reference.md lists the verb, and the daemon and CLI tests cover removal, pruning, each kept reason and the usage errors.
rig queue show, transitions, list, update and block gain --host <id>. The CLI resolves the id in its host registry, together with the learned bindings the queue forwarder would use (the forwarder's eligibility rule, shared through the new @openrig/daemon/learned-host-bindings export), and calls that host's daemon directly with its bearer; a write uses the cross-host timeout and is recorded there as <seat>@<rig>@<this host>. --host local, or the selected daemon's own host id, stays on the selected daemon. An unknown host, an SSH-only entry or an unavailable bearer stops before any request (unknown_host, host_not_http, host_bearer_unavailable, or host_registry_unreadable when the registry cannot be read), and a learned binding that isn't eligible is named. A write's receipt keeps the host it used, and printed follow-up commands carry the same --host; a not-found read on the local daemon also suggests naming the host. claim takes no --host, because only the destination seat on its own host may claim. The cross-host skill (both copies), its digest and cli-reference.md are updated, and queue-host-existing-row.test.ts covers each verb, binding eligibility, the receipt and the error paths.
In the tmux control-client exec (used only with OPENRIG_TMUX_CONTROL=1), set-option joins the routed commands when every flag up to the option name is read and it names an absolute target (=session, %pane, @window, $session id, or a session part before ":") and expands no format (-F). A lone "load-buffer -b <name> <path>" on the same server is read by the daemon, non-blocking, with O_NOFOLLOW, regular files only, at most 4096 bytes, and sent as "set-buffer -b <name> -- <text>" when the bytes are non-empty valid UTF-8 with no control character and no trailing ";". The file is read only once a control client is connected, and a load stays a spawned load-buffer when the server has an after-load-buffer or after-set-buffer hook set, globally or on any session, asked once per client; otherwise any other shape, a failed read, or a read that doesn't return exactly the size the stat saw (a short read or a file written meanwhile) also keeps the original spawned load-buffer. A set-buffer a caller sends is not routed. tmux-control-read-short-file.test.ts covers the short and grown reads; tmux-control.test.ts covers the routed and kept-spawned set-option forms, the set-buffer argv and each kept-spawned file case; tmux-control-native.test.ts checks on real tmux that a pasted text lands byte for byte with nothing spawned, and that window, session and pane options set through the client read back from a spawned show-options.
The Claude activity-hook reader now also reads the node's validated launch recipe. When it has an absolute rigRoot, a local: agent ref is resolved against that root (re-anchored for shipped specs, as before), and the agent.yaml found there is used when its spec name matches the seat's resolved spec name; otherwise the selection reads unknown. Nodes without a usable recipe keep the recorded-roots search, which now skips a root that resolves the ref back to itself, since it is no independent evidence of the agent's source, and names that case when it leaves no folder. restore-claude-activity.test.ts covers the wrong-root case, ordinary local:. recovery from the recipe's root, an added member, retained development checkouts with dot and non-dot refs, a reverse selection and a missing development agent.
For an ordinary Claude Code or Codex send, when the transport has the transport.draft_delivery reader and the send carries no input rule of its own (managed compaction, a prompt override), SessionTransport reads the input box once just before the paste, within the input-box read deadline. If the box reads typed and the setting is refuse, the send returns target_needs_input with activity prompt_draft before any input, as a recognized draft does, and the queue's existing retry applies. If the setting is deliver, the result's advisory says the message was pasted into the draft and submitted with it, said once per send. An unreadable box and a transport without the reader keep today's behaviour. rig config help names the refuse-mode retry limit and that the draft's own activity wins under --wait-for-idle.
rig workflow trace gains --full, which passes ?full=true to the daemon's existing GET /api/workflow/<id>/trace route; there is no new daemon route. Without the flag the request has no query, as before. --json prints the daemon payload unchanged. Human output adds one line naming the --full command when the trail came back with trailCompleteness recent. docs/as-built/cli-reference.md lists the option.
Adds rig context reconstruction inspect|view <revision>, which reads exact immutable v0 base records, replays deltas and follows pinned compositions from local stores, with no daemon call, skill install, context projection or remote fetch. The catalog project and work home are resolved through catalog-selection logic extracted from work-install, so both use the same selection. Inspect shows source manifests, purpose, depth, dates and approximate size; view keeps constraints, authority, gaps and transitive dependencies, shows actual omissions apart from authored coverage, and keeps inherited observation dates. A missing record or reference stays an explicit incomplete result. A store that exists on this machine is read at its original path; a store that moved needs a read-time mapping, and immutable pointers and revision IDs are never rewritten. Canonical hashes are Python-compatible for safe integer JSON values, and unsupported numeric representations are rejected. Every byte read is charged against a 32 MiB budget before it is decoded or parsed, and later reads stop once it is spent. docs: reconstructions.md and project-workspace.md describe the commands.
A shared retirement in seat skill storage removes an automatic core copy only when its ownership and source-root records identify it as a copy from the directly loaded core's real path, its tree still matches the recorded digest, core still supplies the skill, no explicit selection names it, and no skill-projections record or following entry names it. The copy is moved through the existing bounded staging with rollback, and its id is removed from every topology owner's selection so restore and saved-loadout apply can't recreate it. Edited, linked, unreadable, unrecorded and projection-recorded copies are kept and named, and seats without direct core are untouched. It runs before the harness reads its plugin roots: in launchNode before the session for a fresh, restore or launch start, and after takeOverPane succeeds for a kept-pane relaunch; in a handover after the predecessor is proven gone and before respawn; and in set-runtime only when the runtime actually changes. Saved-loadout apply is unchanged from main. Its notes reach the launch, fresh-launch, handover and switch results as warnings, and no new refusal is added.
CurrentModelReader.readEffectiveModel(), which the divergence monitor uses, now reads the newest model record's model and time for Claude and Codex and applies observe()'s rule: a record at or before the current occupant's tenure boot reads pending ("no model record from the current occupant yet"), and a record whose time or boot can't be placed reads pending too. Pending is the monitor's existing no-verdict state, so it checks again at the next poll and never settles a verdict on the predecessor's record. A resumed conversation keeps its predecessor's records in the same file, which is how the false verdict arose. Pi's reader is unchanged. A Codex seat with no registered occupant tenure now reads pending, with the monitor's existing unchecked warning, where it was compared before.
Adds packages/daemon/assets/plugins/openrig beside openrig-core, shipped in the npm package. Its Claude Code manifest runs rig mcp serve --front-door with OPENRIG_SESSION_NAME from the plugin's seat setting, the app's address on the person's team as a hosted member; there is no root .mcp.json and no hooks. A marketplace manifest named openrig-app installs it from the package already on the machine, and a Codex manifest connects it with one command. One new skill, working-with-an-openrig-team, describes the loop around the existing MCP tools; its home is the spec copy, and mirror-skills generates the canonical and plugin copies and routes it in the skills index. rig mcp serve --front-door gives the plugin's seat front-door instructions (the person's questions and words, with no member work-loop authority), and rig_whoami answers a declared hosted seat with no session when its rig exists; the default instruction modes and tool exposure are unchanged.
CLAUDE_STATUS_WARNINGS, the exact rows that may sit under Claude's mode footer, gains one alternative: "✔ Update installed · Restart to update", the wording Claude 2.1.319 prints on its own row in a narrow pane. The pattern stays anchored at both ends and still matches "/restart to apply" and "Restart to apply" exactly, so the footer search reaches the mode footer and the draft-aware reader, activity classification and send readiness read the prompt as before; a near miss still reads unknown. The versioned banner and the /rc row were already handled. Tests add the row to the warning-footer readiness matrix and an empty, typed and near-miss composer case.
rig acp serve's agent server asks the person: during an open turn it polls the caller's open asks and puts one to the app as session/request_permission only when its tags carry this chat's exact acp-session:<id>; an untagged ask from a seat the turn's work reached is named, never asked. A choice posts the existing Mission Control answer verb, so it is recorded by the same answer contract Slack and the TUI use, with the actor taken from the caller's session header; a refused answer is shown with the daemon's reason. On cancel the turn waits up to 2 s for questions the app is settling, then finishes them before returning cancelled. Sessions are the caller's acp-serve rows tagged acp-session:<id>: session/list groups them, session/load replays each root through the same chain follower a live turn uses, and a later prompt carries the session tag and names the earlier roots. session/load of an unknown session returns JSON-RPC -32602. The answer POST sends OPENRIG_AUTH_BEARER_TOKEN only as its Authorization header. docs: acp-agents.md describes questions and sessions.
Adds transport.draft_delivery (deliver or refuse; default deliver) to the CLI config store, the daemon's settings store and the TUI config model, with OPENRIG_TRANSPORT_DRAFT_DELIVERY taken first when it is exactly deliver or refuse. The daemon reads it at each send that meets a person's draft: only a readable refuse opts out, and a value or read that fails falls back to deliver. Under deliver, SessionTransport pastes the message at the current cursor into the draft and sends Enter, so the combined input is submitted. Under refuse, the send is refused as before and a work notice is retried once the prompt is clear. Menus, permission prompts and hook-reported requests still refuse, a per-seat typing guard still holds, and managed compaction still waits for an empty prompt. rig config help describes both values and that a draft OpenRig can't detect isn't mentioned in the result.
Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol.
An external (non-tmux) self-attach resolves its session name in SelfAttachService.resolveContext. The explicit name (an external_cli context's sessionName, or else --display-name) is kept exactly when it already contains @ or is already saved for the same node in sessions; any other explicit bare name becomes name@rig. With no explicit name, the derived default stays logicalId-with-dashes@rig, as before. A tmux context passes through unchanged. The attach command's --display-name help says new bare names gain @rig. A member already bound under a bare name keeps it on an ordinary re-attach, and so stays unaddressable by the queue until an explicit repair.
A whole-file rig config reset now names the keys whose effective value still comes from an environment variable, and keeps the existing message when there are none. If reading the settings back after the reset fails, the reset is still reported as successful (exit 0) and the read-back problem is noted on stderr. Single-key reset is unchanged. Contributed by dajiaohuang; reported in #2528.
rig acp serve runs OpenRig as an ACP agent over stdio. A prompt becomes a queue row to the receiver named by --to, attributed to the caller (--as, the seat environment, or the default person) with the session's follow-up lineage in its body; the turn follows the row and its handoffs, including handoff-and-complete's done/handed_off_to form, and ends with the team's result, naming the closer from the row's closing transition. The row ID is chosen before the create: when a create gets no answer, a readback that finds the row carries on, and one that doesn't keeps the ID as unconfirmed in the session's lineage; an answered 4xx is a plain refusal and a 5xx stays unknown. The JSON-RPC peer splits into the existing client end and a new stream end over stdin and stdout, with no protocol SDK dependency. The queue is the only store, and a 2 s poll follows open turns.
The terminal broker's attach snapshot and scroll-back window now request tmux captures with escape sequences, so colour, bold and dim survive the first paint. The painter keeps tmux's carried styling across rows instead of resetting each row, resets once before clearing the screen and once after the last painted row, and replays the styling of rows above the first painted row when a height crop or the scroll-back window drops them. The captures feed only the websocket painter; activity, input-box and prompt detection keep their own plain captures. Reported by niklasbaldi in #2598.
daemon-http-clients.md no longer says a token never gets past the browser boundary's 403 refusals. A valid configured bearer token in the Authorization header can pass the target-name check for a well-formed but unaccepted Host; it cannot pass a malformed Host or a refused Origin. The sentence links Browser access for the full rules. Docs only. Contributed by dajiaohuang; refs #2486, where the correction was reported.
bin-wrapper enables Node's compile cache except for a bare invocation with stdin or stdout piped or redirected, which only prints usage. That case loaded the command tree only to print usage, and with a cold cache the deferred cache writes near exit could push it past a caller's deadline after the usage was already printed. Any script argument, including piped --help and --version, and a bare run with both streams on a terminal, keep the existing cache attempt, matching the front door's own predicate. A new bin-wrapper test covers the piped and terminal cases. Contributed by seanmcn; reported in #2642.
rig provider switch --account-home and --clear-account-home select or clear an existing Pi seat's account home through a confirmed successor with a fresh conversation and handover; an unconfirmed or failed switch leaves the old account and state intact, and the selection survives restart, resume, restore and daemon restart (migration 151 keeps switch history and creates or copies no credentials). The chosen directory must already hold a readable auth.json, which the runner links into the seat's managed agent directory; env-only accounts and wrappers that create credentials later are outside this path, and an existing seat-local login or unmanaged auth link is preserved through the confirmed switch. Selected accounts load a Node preload that points only their auth-lock operations at the chosen account's auth.json.lock pathname, even when that auth.json is a symlink, and removes its own NODE_OPTIONS entry before Pi starts tools; outside the managed target it does nothing. A Node-hosted launch wrapper that execs Pi can consume the preload, so that Pi gets no shared-auth lock and keeps its native per-seat lock; compiled Pi and wrappers that discard NODE_OPTIONS are likewise not covered. Contributed by seanmcn; requested in #2629.
leavePaneMode passes the seat's session name to cancelPaneMode, as the text and Enter writes do. The guard resolves the seat and writes to its bound pane; the mode reads stay on the bound pane. Before this, the cancel addressed the raw pane ID, and when a stale binding in another rig named the same reused ID, resolveGuardTarget matched two bindings and refused the cancel (guard_target_unknown), so the send failed pane_in_mode with no text sent. A new session-transport cell covers the stale same-pane binding, asserting delivery and submission, the cancel's target, the reads on the pane, and resolveGuardTarget on both forms. Not changed: reconciling stale bindings, and a person scrolling again between leaving the mode and the Enter.
Claude kernel and team launches pass the named policy kernel or team to the PreToolUse hook instead of the base64 policy, shrinking the argument from 988 to 6 characters for kernel seats and from 1,640 to 4 for team seats. The three policy lists move unchanged into a shared CommonJS asset with TypeScript declarations, read by both the launcher and the hook, so every allow and ask decision is unchanged; legacy encoded arguments still decode, and the missing-hook fallback is kept. rig-spec.md documents that named-policy sessions read the installed lists on each later Bash hook call while native inline allowances stay as launched, and that rolling back in place to a pre-name hook gives no hook decision, so the lifecycle asks can disappear under the native Bash(rig:*) allowance until affected Claude seats are relaunched. Contributed by seanmcn; requested in #2639.
Managed Codex seats now pass Codex -c allow_login_shell=false, so tool commands run in a non-login shell and inherit the environment their pane's shell set up, OpenRig's command folder included; plain rig works however OpenRig was installed, with a Codex that supports allow_login_shell. Inherited setup isn't re-run for each command, so setup that lives only in login or rc files, and shell functions and aliases, may be missing from tool commands, and a model request for login: true gets Codex's own refusal. rig config set launch.codex_login_shell true omits OpenRig's override, deferring to Codex's own setting, at each seat's next launch; a restart in place keeps the running choice. Fresh launch, fork, resume, legacy resume, restart in place and the launch-wrapper path each pass the setting exactly once, or not at all when it's on. getting-started.md and the config help describe the trade-off.
When OS policy prevents a reliable process or port check, daemon startup now reports the uncertainty instead of treating a blocked probe as proof that an address is free. Missing state and preflight cases warn. The auto-start stop is narrowed to the shared PID path for a reused or uninspectable PID. Stop refuses unknown process liveness before probing or signalling and preserves the state file. A denied signal probe yields unknown even when the process state is readable, and a confirmed zombie remains dead. A blocked explicit non-loopback OPENRIG_URL stays on the blocked-status path instead of starting a local daemon (rig up and rig start); loopback targets, including localhost, [::1] and 127.x addresses, keep the existing startup behaviour. Refs #275 and #1650. Contributed by dajiaohuang.
Adds --task-destination <member@rig> to rig mcp serve --http --grant-service. For an external caller, rig_queue_create's destination becomes optional; an omitted destination takes the operator-configured receiver. The receiver comes only from that flag, never from caller input, topology, names or history. It is checked against the request's grant with the same team predicate as an explicit destination, before any lookup, and re-resolved on each omitted call to exactly one existing local node; a missing, ambiguous, removed or out-of-grant receiver returns task_destination_unavailable with no write. Explicit destinations, and local and member calls, keep their existing behaviour and still require a destination where they did. The external instructions now teach the create, show, revise and artifact loop. The new option is refused only when misused: it needs --http --grant-service, and takes one local member@rig seat.
Adds an opt-in on_restore policy to resources.skills[] entries in agent specs. keep_saved, the default and what an absent policy means, keeps #2337's behaviour: a restored seat keeps its saved skill copy. follow_source refreshes the seat's copy of that skill from its source when the seat is restored, for Claude and Codex seats, keeping the native conversation. Copies edited, linked or not owned by OpenRig in the seat keep their existing protections. An unrecognized on_restore value adds an advisory that names the resource, the value and the two supported spellings, and is normalized to keep_saved rather than invalidating the spec. On a no-op restore a keep_saved skill reads only the projection record, and the following bookkeeping is best effort, so it can't fail a launch. Documented in docs/reference/agent-spec.md. Requested by seanmcn in #2625.
Kept Herdr viewer journal entries and follow-view saved tiles store the whole tile attach argv. #2614 changed the writable tile script, and #2464 changed both scripts before it, so views kept from 0.7.5-0.7.9 failed the strict comparison after upgrading: a kept viewer read unconfirmed and a kept follow view stopped adding and closing tiles. earlierTileAttachment accepts a kept argv only when it exactly equals what 0.7.9's or 0.7.5-0.7.8's generator makes for the current session, tmux, server and recorded read-only intent; the entry then moves to the current command. A different target, a changed read-only intent, or any other script (including one sharing the old prefix) still mismatches as before. The earlier generators live in src/domain/terminal/earlier-tile-attach.ts, shared with the tests. The approach follows natefaerber's #2467. No new refusal, error code or setting.
Makes the daemon test fixtures hold on macOS paths and under startup contention, without weakening any assertion: readiness waits instead of fixed sleeps, temp-root resolution through the real path, a script-file shell for long commands, serialized mutations where tests raced, a long-path command, and the shared hermetic test setup. The refocus-overlap cell still asserts the lost pendingOn race, now through a deterministic handshake (the time budget only scales timeouts). The existing Linux CI job that covers /proc seat environments also runs pi-shell-launch-native.test.ts, and the Linux long-command session tokens stay within their limit. Test and setup only; no product code changes. Reported by seanmcn in #2627.
On the first inherited tmux server selection with persistence on, tmux-server-selection creates the home directory (recursively, best effort) before writing the record, so the recorded home is the canonical path a later boot or a later switch to isolation reads it back by, even when the home sits under a symlinked parent. If the home can't be created, inheritance carries on without it as before; inheritance has no writable-home prerequisite. Homes already recorded are unchanged. Reported by seanmcn in #2632. No new refusal, error code or setting.
For You cards for local asks with humanQuestions show the questions with the card's existing controls: one option per question, or an answer in the person's own words, recorded through POST /api/mission-control/action with the ask's revision, then sent once every question has an answer. The card names who OpenRig records as answering (human@host (unattributed), as the TUI does). Live attention keeps priority in the 50-item window, with answered receipts using spare room, and saved session snapshots are bounded with it. A receipt says "answered and returned" only on the answer outcome or reply evidence; a plain close shows as done with the recorded choices, and no closing-note text is read as an answer. Finished asks without questions move to Shipped again. The shared action hook keeps remote_action_failed detail and status. Cards from a paired remote host keep the TUI hint; CHAT, asks without questions and runner permission asks are unchanged.
ROADMAP.md's released section moves from 0.7.8 to 0.7.9: the heading, the release link, and the published 0.7.9 notes' first two sentences with "OpenRig 0.7.9" written as "it", as the 0.7.8 update did. The pointer to the release notes for what shipped, known issues, upgrade instructions and contributor credits is unchanged. Text only.
The two unverified-custody messages a manual close gets (a successor named on another host, or no local row with the source's lineage) now name the route that makes the handoff verifiable: rig queue handoff or handoff-and-complete with --to <seat@rig>, and --host <registered-host> for a successor on another host. That route creates the successor there first and closes with the derived key the stuck sweep already trusts. The sweep, its custody rule and the CLI command definition are unchanged: a registered-host name alone is still not proof that the work was handed on. No new refusal, error code or setting.
Adds an optional member-level launch_wrapper for local built-in Pi and Codex seats: an argv prefix applied at native launch and carried through restore, handover, restart and runtime switch (migration 150 stores it). For Pi the wrapper wraps the child process the runner spawns, inside the unwrapped runner; flat specs carry the field. Once selected, a wrapper is never silently skipped. rig-spec.md says retention applies to existing seats, not newly imported nodes. Native cells cover the wrapped paths with an rc PATH export, a readiness marker and wrapper execution markers. Sandbox, memory and tooling remain the operator's choice. Reported by smota in D#2328.
drainPendingWakeIntents skips a failed or indeterminate wake intent while its seat has no receiver: the destination is unroutable, or the seat's current session, resolved through physicalSessionName, is exited or detached. The check runs at every claim, including grouped and correlated claims, so a seat that comes back during the pass gets its later wakes, and only notifications nothing in the pass attempted are counted as waiting. A never-tried intent always gets its first attempt; running, unknown and non-pane-bound seats take the old path, and an error in the check fails open. The skipped rows stay pending and owed, with no write, and the start-up log line adds waiting=N and names the seats. The wake ladder and stuck sweep are unchanged. No new refusal, error code or setting.
A timed-out or unreadable rig stream emit can still land late. Its error now names the retry that can't add a second item: with --id, repeat the same emit with that --id (the daemon keeps one item per stream item ID) or check first with rig stream show <id>; without --id, check with rig stream list --source <recorded seat> --since <five minutes before the send>, using the seat the daemon records, and pass a stable --id on later emits. queue create is unchanged; its existing --id already makes a retry safe, and the openrig-user skill now says so for both in one paragraph (skill digest updated). No new refusal, error code or setting.
tileAttachArgv chose ignore-size for a writable tile whenever another normal client was attached to the session, and under window-size latest such a tile could never size the pane, so the pane kept an older client's dimensions. Writable initial, fallback and replacement attaches, and the writable recovery command, now attach without ignore-size; read-only attaches keep -r and are byte-identical to before. This covers writable rig, pod and seat views, saved writable members through composeView, and interactive task viewers, locally, over inherited SSH and through the remote helper. The tmux control client's no-output,ignore-size and the CLI's attach-flag recognizer are unchanged. A real tmux test with a competing older client fails on the previous code (14x18 where 101x32 is expected) and passes now (101x32, then 121x43 on resize), with a larger read-only observer keeping its size. Limits: different simultaneous sizes on one shared pane follow tmux's latest-client rule; clients attached before this change keep their flags until re-attached.
validateAgentSpec adds an advisory for any key under a profile's uses that isn't one of the five known categories, naming them, on the existing advisory channel, so it reaches the person at rig up, restore, retry and rig agent validate. It never makes a spec invalid, and hooks stays a validation error with no second advisory. The advisories for shapes that normalization drops now end "; ignored", and a non-string or blank list entry, which is kept and fails resolution when its profile is selected, now ends "; a member using this profile fails to launch". The agent spec reference says so in one sentence. No new validation error, refusal or setting.
CHANGELOG.md gains the [0.7.9] section from the v0.7.9 tag (ddb9d25b), placed above [0.7.8], with the status line "Published 2026-11-06 as @openrig/[email protected], built from the commit tagged v0.7.9 (ddb9d25b)." Where the tag's section and the published release notes differ, it uses the published wording: the summary (including its credit to the people who reported or contributed), the OpenShell worker section, and the Known limits, including "What we ran end to end under the runner contract". The OpenShell heading is "The OpenShell worker in 0.7.9" so its anchor is unique in the file. Nothing outside [0.7.9] changes; whats-new.md stays as it is, since main's package test pins its heading to main's package version.
A profile's uses.<category> written as a non-blank scalar validated but was silently dropped, so an agent could start without the selected skill. normalizeUses now turns a non-blank string into a one-item list for all five categories, and validation adds an advisory suggesting list syntax. A uses value that isn't an object, a category that isn't an array, and a blank or non-string entry are advisories, not new validation errors, so a malformed unused profile can't stop a spec; arrays reach resolution unchanged. When a profile is selected, its coerced scalar resolves exactly like a one-item list: a resource the pool doesn't hold fails that member's resolution with "not found in resource pool", and a non-string skill ID now returns "has a non-string skill ID" before discovery instead of throwing. The advisories travel with the spec's existing advisory channel. Limits: no trimming of scalar values (as with list entries); unknown uses keys are not yet advisories.
scripts/fixtures/packaged-plugins-last-release.json now names release 0.7.9, its published archive (sha256 668b9305...) and the openrig-core 0.1.25 payload it shipped (f1838261...), byte-equal to a --snapshot of the published npm tarball and to the v0.7.9 tag's plugin tree. openrig-core moves from 0.1.25 to 0.1.26 in both harness manifests: its files changed on main after 0.7.9 (permission-stop descriptor, refocus and the trace script), and check-packaged-plugin-versions.mjs requires a greater version when packaged bytes change, so existing installs don't keep old content. The check fails at 0.1.25 and passes at 0.1.26; nothing else pins 0.1.25. No other file.
The rigs skill's Install step offered only npm install -g @openrig/cli. When npm's global prefix isn't writable (often a system Node with prefix /usr), an installing agent improvised a project-local install, and Codex seats then reported rig: command not found. The step now has the agent first point npm's prefix at a folder the person owns whose bin is on their login PATH (for example npm config set prefix ~/.local when ~/.local/bin is on it), saying first that this changes their npm settings and, if needed, their shell profile; use the Node of a version manager such as nvm instead, since nvm refuses an npm prefix setting; and install inside the project only if neither is possible. It links getting-started's "Before you start" for setting and checking the login PATH. Both copies, skills/rigs/SKILL.md and packages/daemon/assets/skills/rigs/SKILL.md, stay identical. Text only; no code, command or gate. Limits: it makes the project-local fallback rarer but doesn't fix why managed Codex seats lose OpenRig's command folder after one (separate work); not run with a live agent install.
The post-commit notification drain in EventBus read the whole remaining event tail into one array. It now reads ordered SQL pages of at most 500 rows (seq after the cursor, ordered by seq) and keeps paging until a read comes back empty. The per-row cursor, subscriber fanout, nested standalone and envelope emits, and poison recording are unchanged; replayAll(), SSE replay and client buffering, and event retention are untouched. One test commits a tail larger than two pages through real SQLite, with subscriber emits at page boundaries and a malformed row, and checks the same order for two subscribers, one poison notification, no redelivery, an uncapped replayAll(), and that no SQL read returns more than 500 rows. No new refusal, error code or setting. Limits: this bounds rows per query, not bytes; the drain still runs synchronously to quiescence; no latency change on a live install is claimed.
validateAgentSpec already produced non-blocking advisories, but loadSpec dropped them, so only rig agent validate --json and the HTTP route ever showed them. loadSpec now carries them on ResolvedAgentSpec.advisories, rigPreflight turns each base or imported spec's advisory into a warning naming the member, the spec and its path, beside the existing import-collision warning, and those warnings ride the existing preflightWarnings that rig up prints. A recipe-backed fresh restore (launchExistingAgentMember) and a successful first-start retry now return the same advisories through their existing returned warnings, once per member, spec and advisory, without doubling where preflight warnings are already merged. Human rig agent validate prints one agent spec advisory line per item, valid or not; --json is unchanged. No new refusal, error code or setting. Limits: a native resume that uses saved context and doesn't reload a spec shows none; unknown uses keys are not yet advisories (a follow-up after #2585).
docs/reference/telemetry.md gains a boundary: OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope, and any projection of it is owned by an external consumer. It describes the separately maintained openrig-telemetry exporter's mapping and limits as that exporter's choices (sampling, observed state intervals, no native execution or trace attribution, downstream freshness), says arbitratedNeedsInput is true, false or null with null meaning no evidence either way, and links the exporter's repository, which documents its installation and supported versions; OpenRig doesn't test or guarantee it. It states OpenRig's own fact that the finite reads are versioned by schemaVersion 1 while the cached rig and node inventory endpoints are operational projections. finite-telemetry.test.ts adds one contract cell through the production telemetry routes: the envelope and limits, decimal IDs above JavaScript's safe-integer range across the archive and active tables, the note-free row shape, an opaque cursor that round-trips, page counters, the cap reason and a retained sequence gap. No exporter code, configuration or tests are included, and no daemon or CLI behaviour changes. Limits: the contract cell pins the envelope strictly, so an additive field under schemaVersion 1 needs the cell updated; the exporter itself wasn't reviewed or run here.
The agent-spec reference described plugin skills as always projected into the seat's skill folder. Since the duplicate-loading fix related to #2426, a Claude seat that selects the vendored openrig-core gets that plugin loaded directly, skills and hooks included, and resolveNodeConfig skips projecting its skills; other selected plugins, Codex seats and non-vendored copies still project. The skills section and the plugin table now say so, and that a seat that didn't select openrig-core doesn't get its hooks this way. Docs only. Limits: one sentence says the daemon loads the plugin through CLAUDE_CODE_PLUGIN_DIRS; the daemon sets that variable and Claude loads the plugin, as the table row says. The direct load depends on a Claude Code version that reads that variable, which refocus-channel.md states for the hooks.
agent-spec.md's fragment section now says a fragment only adds tables to config.toml, so it can't install a named Codex profile in either form: Codex reads a profile from its own <CODEX_HOME>/<name>.config.toml with top-level keys and won't load one while a legacy [profiles.<name>] table remains; it points to creating the file and selecting it with codex_config_profile, and notes that the profile preflight checks it before a launch or restore and names the file for a legacy table. getting-started.md says builtin:yolo drops the whole profile, not only its sandbox and approval settings, and how to keep a profile: full access inside the profile with permission_policy absent, or a workspace-write or read-only codex_permissions envelope, which keeps -p and adds its flags; full access isn't an envelope. rig-spec.md's codex_config_profile row says the same in one sentence. Docs only; no behaviour or contract change.
Podman registers its native /libpod routes only under a version prefix, so the observer's unversioned /libpod container list and inspect calls got the router's plain-text Not Found, which the reader then failed to parse as JSON; an owned delete stopped unconfirmed at the cleanup inventory. The observer now uses the compatible /containers/json?all=true and /containers/<id>/json routes, which Podman serves with or without a prefix and whose Id, Names, Labels and Mounts fields the existing selection already reads. Volume absence no longer comes from a per-volume 404: one /volumes listing must be a 200 with an array of named Volumes and no Warnings, and presence is name membership in it; anything else is environment_cleanup_unconfirmed with the HTTP status. The reader parses JSON only for a 200, and malformed 200 inventory is a typed unconfirmed error. Socket identity, workload and supervisor selection, ownership and lifecycle are unchanged, and nothing is sent to the engine but GETs. Limits: container rows are still checked only for status and array shape, so a 200 of [{}] with an empty volume list would read as absent (unchanged here; Podman emits full records or an error); a thrown cleanup error still omits its stage and dispatch status; this is a snapshot, not an atomic inventory; maintained native owned-delete isn't requalified by this change.
rig exec run --wait installed its SIGINT listener only when polling began, after it had printed that the run had started, so a Ctrl-C landing between the two met the default action and killed the CLI with no exit code (the run itself, in its own session, was never affected). armWait now installs the listener before each path announces the run: a run the row already has, a caller that lost the claim to another, and a fresh start right after the connector is spawned, where the start-wait loop also stops when the waiter leaves. report removes the listener in a finally, and waitForEnd takes the waiter instead of arming its own. A tests-only afterAnnounce seam stalls the CLI just after the announcement; the new cell sends a Ctrl-C to the whole process group into that stall and expects exit 3, the stopped-waiting line and the run completing. Limits: a Ctrl-C before the connector is spawned still meets the default; an exception before report would skip the listener cleanup, though the CLI exits on it; the pre-announcement path is traced at source, not separately exercised; a run that has already ended still exits 0 or 2 by its outcome.
The OpenShell guest's launch builds the Claude child's environment from scratch and copied only ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY, so a supervisor-supplied CA never reached the worker and its TLS failed with SELF_SIGNED_CERT_IN_CHAIN. The same explicit list now also carries NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE, each copied only if the guest's exec environment has it; when they're absent the launch is as before. No broad environment copy, no TLS bypass, no credential, and provider references, argv, latch and lifecycle are unchanged. Limits: NODE_EXTRA_CA_CERTS is the demonstrated repair for Claude; the other three are the CA selectors this exec supplies, kept for the agent's own OpenSSL-based tools, and no record isolates each as needed. This preserves the supplied references; it isn't a claim that the maintained OpenShell worker is qualified end to end (export, finite completion and exact restore are unproven).
resolveCrossHostTarget returns localHost: true only from its self branch, when the qualifier is the daemon's own host ID, and only after the existing check that refuses an explicit --host naming a different host. rig send then leaves opts.host unset for that target, so a persisted remote selection no longer catches a send that names this host; the documented order (explicit --host, then target sugar, then the persisted selection) holds again. The early --context refusal now covers only fan-out, and the remote --context refusal runs after host resolution, so explicit, persisted and @remote sends with --context are still refused before any remote dispatch, now after the local health read. Limits: when the self-host ID can't be read, the qualifier isn't recognized as local and the selected host may still be used, as before; rig capture and rig transcript don't pass the self ID, so @<self> isn't stripped there (unchanged); the tests use a local HTTP fixture, not a real second host.
rig exec runs a queue row's task on Claude Code or Codex through their maintained ACP adapters, detached: a hidden rig exec connector process owns the run and outlives the calling CLI, the run file under $OPENRIG_HOME/acp/runs records its process identity and typed state (requested, started, finished, interrupted), and the queue row keeps custody and acceptance and is never closed by the run. rig exec harnesses and run --plan show the adapter and native versions and how each harness answers. A row gets one run: claims are published once per generation with a create-exclusive hard link, a retry after an uncertain submit returns the existing run, and --again starts another only after it ended. Each run's start and end are separate slots, each published once, so a reconciler and the connector can't both settle the same transition. --wait only polls and survives Ctrl-C; its exit follows the outcome (0 completed, 2 otherwise, 3 if the waiter leaves). A dead or reused process, or a connector that never recorded itself, reads interrupted, outcome unknown. Cancel is acknowledged, not settled; usage and cost carry their source, and unavailable cost reads unknown. rig acp run stays synchronous and adds only a usage line. Limit: the crash and cancel guarantees are rig exec's; rig acp cancel given an exec run's id while it's requested can signal the requester rather than the connector, so use rig exec cancel.
QueueRepository.isRegisteredHumanUpdate(row) is true only for a pending row with human_intent update whose destination resolves to a currently registered person; it reads false for an agent, an unregistered destination or an unavailable registry. The waiting view treats such a row as informational correspondence with no claim backstop, and the stuck sweep skips it as an unclaimed obligation, except when its quiet-hours hold has been orphaned (no live release job), which is still reported as before. The new resolution reason closes only the sweep's own unclaimed-obligation finding, never while the row is quiet-held or has a delivery outcome. A shared recovery for such an update can be relabelled to an undelivered human notice instead of being closed, so a later delivery failure stays visible. Posted and on-Steering rows keep today's paths. This covers the informational-update part of #514, not the rest of it.
With mouse mode on, scrolling a seat's pane puts it in copy mode, and tmux stacks modes: copy mode can sit under run-shell output (view mode), a chooser, customize or clock mode. A single send-keys -X cancel leaves only the top copy or view mode, so a pane with two modes stayed in a mode, and its sends and wakes were refused with pane_in_mode until someone left the mode by hand. cancelPaneMode now runs tmux copy-mode -q, which leaves every mode the pane is in and types nothing on a pane in no mode; when that fails it falls back to send-keys -X cancel, checking the input guard again first, as the Enter fallback does, so input that arrived meanwhile stops it. leavePaneMode reads the result and adds the reason to its refusal. Behaviour change: a send or wake now also leaves a chooser, customize or clock mode a person has open in a seat's pane, where it was refused before; #912 already leaves scrollback.
docs/reference/acp-agents.md adds "Claude Code and Codex, through their ACP adapters", checked on 2026-11-06 with OpenRig 0.7.8 on one small file task per case. It covers the install for @agentclientprotocol/claude-agent-acp 0.101.0 and @agentclientprotocol/codex-acp 2.5.0 and the native versions each runs (Claude through CLAUDE_CODE_EXECUTABLE when set, otherwise the SDK's platform binary, unchecked; installing with --omit=optional leaves that binary out, and without CLAUDE_CODE_EXECUTABLE the adapter then refuses to start rather than falling back to a claude on PATH). Each agent keeps its own permission mode; the settings that make each one ask are the caller's choice, not a recommended default. Claude's reject lets the turn continue, while Codex's only refusal, cancel, ends it. A missing session is refused by both with -32002 or -32603, and neither code alone means a missing session. A completed turn isn't finished work: check the work before closing the row; beyond one observed case, long and background work weren't checked. rig acp cancel was acknowledged in 20-30 ms for both, sent while the agent waited on a permission answer; a cancel mid-generation or mid-tool wasn't timed, and an acknowledgement isn't a settled shutdown or zero further spend. Docs only.
docs/reference/community-harness-examples/dsh/dsh.yaml's first limit now names the two stacks a real dsh 0.2.1-rc.2 has run on with this runner: dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with deepseek/deepseek-v4-flash on OpenRig 0.7.8 (queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel). In both, the permission answers came from a test answerer registered as a person; dsh's DeepSeek Messages adapter via OpenRouter failed with HTTP 400, cause not established. Its resume limit says what was observed (a real dsh reopened and recalled a conversation with resume added to a copy of the preset; a missing conversation's refusal wasn't run) and resume stays undeclared. harness-runner-contract.md adds a "Since 0.7.8, on one stack" paragraph after its not-shown list. CHANGELOG.md's [0.7.8] Known limits line that said the dsh example was checked only against stand-ins is corrected in place, with the old sentence struck. Docs only.
rig seat set-runtime's handover read the seat's authored recap with authoredRecapResolver(ctx.seatRef), the logical ID (for example dev.impl), while recap folders follow the bound canonical session's member (dev-impl), so a pod seat's switch missed its own recap. It now passes the bound sessionName. A test covers a seat whose logical ID and member differ. Limit: the ordinary handover path still resolves recaps from its own input.seatRef, which already accepts logical aliases; that path is unchanged here.
A queue listing asks findQueueRecovery once per row, and each ask ran json_each over every recovery-tagged row. withRecoveryIndex(db, fn) now opens a scope for one synchronous listing; inside it, the first lookup builds an index with the same json_each, the same recovery tag filter and order and text values only, and later lookups read it, while a compact listing that never asks builds nothing. Outside the scope every lookup is the original per-row query, and nested calls share the scope. QueueRepository.list wraps its existing body. Limits: the index assumes recovery data doesn't change during the listing; nothing a listing calls changes recovery membership, state or its ordering time, although the activity reader it calls can write other events, and a callee made async would simply fall back to the per-row query. Equivalence was traced in source and checked against an offline copy of a real queue; it wasn't measured on a live daemon, and malformed Unicode tag values weren't compared.
The openrig-core refocus hook marks a `rig queue whoami` lookup that ended in ETIMEDOUT and passes `--work-unknown-timed-out` with `--work-unknown`. With `--unresolved-work-advisory` (passed since #2582), trace-to-root.py prints "TRACE GAP — current work node UNKNOWN: queue whoami failed: spawnSync rig ETIMEDOUT" plus the project-level chain, labelled a broad orientation and not evidence of a current mission, and doesn't fail `--check`, so the timeout no longer counts toward "Refocus failed on N consecutive attempts". Non-zero exits, other spawn errors, unreadable answers, no remaining budget and a run without the advisory flag stay strict. A timed-out refocus now clears the pending post-compaction retry, as #2582's advisory does, so a slow daemon isn't queried again on every prompt. The skill digest is regenerated. Refs #2581: the part #2582 left open.
docs/reference/harness-runner-contract.md replaced "No harness has been run end to end under this contract yet, and the native verdict is pending" with what has run natively in 0.7.8's release testing, against a Claude Haiku model: dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) for launch and first input, queue work, a multiline message with an /abort line as one prompt, working through a 52-second quiet tool interval, cancel by a bare /abort, the same conversation after rig down --snapshot and rig up, and the runner and child gone after the managed down; and the dsh example runner, whose native permission request reached a person, with allow-once taking effect and reject-once not. It also lists what wasn't shown natively: a DeepSeek model, the example runner's own resume, the default permit when nobody can be asked, a typed or Slack answer or a second person's answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path stays unqualified until it's shown. The status line above now matches. docs/reference/author-contracts.md's dsh/ACP row says the same in one sentence. Docs only.
rig roster show and find can see one seat through more than one route, for example two host aliases. They now build each route's activity (state, reason, sampled time) and compare them. When the routes agree on state and reason, the activity is kept, with the latest time if every route gave a comparable one and no time otherwise. When they disagree, or one route has no activity, the seat reads unknown with the reason "Conflicting activity observations" and no time, whatever the route order. A seat seen through one route reads exactly as before. Only the CLI's roster enrichment changes; the daemon is unchanged.
The openrig-core refocus hook passes a new --unresolved-work-advisory flag to the refocusing trace script. With that flag, when the work basis says typed work did not resolve (no matching node, or a mission or slice that resolves to more than one directory), the trace prints "TRACE GAP — no single current work node" with the reason, then the project-level chain labelled as broad orientation, not evidence of a current mission. The hook can then tell that advisory from an operational trace failure. Without the flag, and for conflicting tags, several fully resolved nodes, or an unreadable queue response, the script stays strict and exits nonzero as before. The skill's edge digest is regenerated. Refs #2581; the separate queue whoami timeout case is unchanged.
rig slack status adds "Relay attempts awaiting response: N" for the local Slack relay, beside the retained inbound records. The daemon's inbound router counts each forward to another host while it's awaiting a response (incremented before the send, decremented when it settles, success or not), and the connector status reports that number. It's point-in-time and covers only the current connector instance: after the Slack connector is restarted, a relay the previous instance started can still be awaiting its response (up to the forwarder's 10 s timeout) while the new count reads 0. Zero doesn't prove quiescence across hosts or instances. Routing, retries, retained records and the forwarder are unchanged.
Since 0.7.7, each managed post-compaction step (the turn boundary, the restore request, the audit) was pasted only into an input box that read empty or held a likely suggestion. A box OpenRig couldn't read as empty, such as one with the cursor outside it or a layout it doesn't recognise, held the step on every poll with no end, so a seat could sit idle with its restore never requested. Now, after two consecutive idle polls that read the box as unreadable (not typed and not typed with a suggestion), the next poll sends that step and records it as delivered over an unreadable input box, never as empty. A typed draft, typed text with a suggestion, a seat whose draft-aware delivery or typing guard holds OpenRig's input, and the step after a /compact whose result was lost all keep holding as before. rig compact --state and rig seat status say which applies. The owner notice names the manual takeover: rig compact <seat> --cancel, then a restore request naming the newest map, the restore marker and its packet; a step already being pasted can still land. An unrecognised draft in an unreadable box may receive the restore text. Refs #2575.
GET /api/views/execution (the TUI's execution view and rig view show execution) read and parsed every file in the global review registry once for each slice it built, all synchronously. With 450 review files that was 3,600 reads for an 8-slice mission and 13,500 for a 30-slice one, per request. It now reads the registry once per build and shares it across that build's slices and dependency ladders, and it yields to the event loop every 32 files or 8 ms while it walks them. The next build reads it again, so there's no cache across requests. Each slice's join is unchanged: slice-local proof drops come first, then the registry's, with the same slice match, the same local QA rule, and INDETERMINATE when neither root can be listed. A project-scoped view still never reads the global registry. The other synchronous parts of the view (the candidate query, the wave map, the default mission and serialization) are unchanged.
A rejected request on a route that uses the terminal bearer token (terminal follow and viewer, transport, compaction, and the terminal-protected session and node guards) now gets a 401 that says "Terminal auth failed" and tells the person to send OPENRIG_TERMINAL_BEARER_TOKEN, or OPENRIG_AUTH_BEARER_TOKEN when terminal auth falls back to it. Before, every such 401 said "Mission Control auth failed" and suggested setting OPENRIG_AUTH_BEARER_TOKEN and restarting the daemon, which was wrong when a separate terminal token was set. An optional diagnostic context on the shared bearer middleware changes only the wording. Token lookup, parsing, constant-time comparison, the access decision, the status code and the response's fields are unchanged, and no token value appears in a response. Mission Control routes keep their wording. Refs #2484.
The packaged capability map takes in 0.7.9's changes, and its header marker moves to capability-delta-v0.7.9. Two lines 0.7.9 made stale are corrected: an emit now opens or notes an intake row for queue.worker, and any registered person can settle an ask asked of someone else, recorded as themselves. It also covers indeterminate receipts, --closure-required-at, guidance placement for fresh Pi and Codex 0.164.0+ seats with the cases that still use the repository's AGENTS.md, what rig down takes back and keeps, the rebuild for wrapper and symlink Claude launches, and the daemon HTTP client reference. rig-spec's managed_blocks and guidance_merge rows and agent-spec's target row say where guidance normally goes. Help text: rig stream emit names the intake row and how to keep emits passive, rig send --verify says exit 1 can mean unconfirmed, and rig down says what it takes back and what it keeps. Docs and help text only.
rig seat stop, clean and in-place restart still refuse a session recorded as adopted. When the seat's last completed handover was a fresh, fork or rebuild handover, the refusal now says that a successor from before OpenRig 0.7.9 keeps the adopted origin until its next launched handover or managed launch. It no longer suggests rig unclaim, which would end OpenRig's management of the seat. rig seat stop's help says the same. rig broadcast prints UNCONFIRMED instead of FAILED for a recipient whose text or Enter may have reached it (input attempted or written), and points to rig capture before any resend. No refusal, error code or setting is added or removed.
An optional Claude PermissionRequest hook in openrig-core records each permission request's time, actor category and a fixed tool descriptor, plus Claude's own permission_suggestions in their native spelling, labelled as suggestions (missing, unsupported or size-cut entries say so), in memory within the seat's actor, generation and expiry bounds. rig seat status and both seat-status routes show it when an observation exists, escaped as data. Nothing is applied, approved or persisted, and no hook decision or permission write is made. Refs #915. Contributed by @Aummadour.
The packaged capability map, which agents read on every install, drops three sentences 0.7.8 made untrue (mission following isn't included; a community harness gets no approvals; a later hold notice can show a generic reason) and adds what 0.7.8 shipped: the answered runner permission path with rig seat set-permissions-to, rig environment (with its 0.7.8 not-usable caveat), mission following, rig host edit and remove, terminal.tmux_server isolated, claude_mcp: none, doctor's port-owner warning, rig daemon status's pending-start line, the stopped-seat token switch, and the finished-map expiry states for automatic compaction. Docs only.
rig doctor bounds the cmux status read and the live rig and node reads behind --spec (2 s each, headers and body), through the existing fetchWithTimeout, so a listener that accepts connections but never answers can no longer hold it. A timeout keeps today's outcome: a cmux warning, or conformance unknown, never a matching topology. This finishes the waits #2549 left. Refs #2531.
Stream intake reads past OpenRig's record of a default permit (the runner-permission-permit stream item): it advances its position with no intake row, note or wake, on live intake and after a restart, so the record stays pull-only and wakes no one. Ordinary observations, coalescing and the default permit are unchanged. Refs #2361. Refs #2321.
When a daemon write fails with a JSON body that carries a message but no error field, the TUI's error now includes that message, so launch's session-unconfirmed advice reaches the person. An error field still takes priority, and a body that isn't JSON still shows only the status. Contributed by @dajiaohuang.
The real-tmux transport test waits for a fixture-defined shell prompt before sending input, so a login shell that is still starting can't share a line with the command's output. The fixture's .profile sets that prompt, so the wait holds on Linux and macOS. Test-only. Contributed by @dajiaohuang.
A runner permission ask now says what a reply other than yes or no does: it chooses nothing, OpenRig's default permits the request when the agent offered an allow option, and a runner that forwards the words passes them on only after its turn. Text only: answer parsing, the default permit and the optional forwarding are unchanged. Refs #2363.
rig doctor bounds the wait for /healthz response headers when the daemon port is held by a listener that accepts connections but never replies, and reports an unknown listener instead of waiting forever; the conditional cmux health probe uses the same bound. Reported in #2531. Contributed by @dajiaohuang.
Window-sampling evidence older than the larger of 15 seconds and three poll intervals no longer decides a seat's activity, the same bound hook evidence already has. A seat whose pane the sweep can no longer read now reads unknown rather than its last idle or working verdict, so a stale idle sample can't support a parked diagnosis or an idle-gate wake. Fresh evidence behaves as before. Contributed by @dajiaohuang.
From 0.7.9, each permit OpenRig decides by default on a runner's permission request is recorded once, as a stream item from the seat (tag runner-permission-permit): when no one can be asked, and when an ask is closed without a choice, including a row passed to an agent seat. The record names the tool call, why no person answered (for a closed ask, who closed which row and how), the option the agent got with its kind, and the conversation and request or the ask. A record wakes no one, and one that can't be written doesn't change the permit. For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it. The harness runner contract's section 7 describes it.
The quickstart explains that rig setup --providers records the provider choice a kernel uses when it is first created: with both providers signed in but one selected, the first kernel uses the selected provider. It also covers the OPENRIG_PROVIDER_SELECTED override the daemon reads, an existing kernel keeping its provider, and the warning when setup can't save the choice. Contributed by @dajiaohuang.
rig doctor --spec no longer reports conformance against the first of several live rigs with the spec's name: it skips the check, says conformance is unknown, and lists the matching rig IDs. A unique name is checked as before. Reported in #2535. Contributed by @dajiaohuang.
A refused context-pressure set or reset now says what it compared: the config file's pair, with defaults for values the file doesn't set. Environment overrides apply when each process resolves its configuration and are not part of this check. The validation itself is unchanged. Reported in #2529. Contributed by @dajiaohuang.
A stuck-sweep recovery that stays open now names the reason that remains, so a row that is only overdue no longer carries a stale "stalled after claim" body. Reported in #2533. Contributed by @dajiaohuang.
rig env down, status and logs resolve an exact rig ID first, and when an active rig name matches more than one rig they list the matching IDs and stop before any service request, instead of acting on whichever rig comes first. Reported in #2532. Contributed by @dajiaohuang.
rig context add <url> can install into a fresh nested name: the URL installer now creates the missing parent namespace after the staged pack passes validation and just before the rename, the same step the local-directory installer takes. Reported in #2525. Contributed by @dajiaohuang.
tmux 3.4's show-options -v output adds a backslash before a dollar sign followed by a letter, an underscore or a brace. When OpenRig reads back a scoped launch's default command from a stable tmux 3.4 server, it now removes exactly that added backslash, so the command runs as configured. Other and unknown tmux versions keep the existing reading, and a failed version read doesn't refuse the launch. Reported in #2555. Contributed by @dajiaohuang.
The packaged-plugin version check now compares against the published 0.7.8 package (archive f175a5b1, openrig-core 0.1.24), and openrig-core moves to 0.1.25 in both harness manifests. Since 0.7.8, the plugin's seat-continuity-and-handover skill changed (#2420) while its version stayed 0.1.24, and the old 0.7.6 baseline let that pass, so existing installs could have kept the old file under the same version. Against the new baseline the check fails at 0.1.24 and passes at 0.1.25.
The README and docs/reference/getting-started.md describe the installer's real step-4 outcome: when the CLI installs but setup doesn't finish, it prints "INSTALLED, SETUP NOT COMPLETE (exit N)" and points to setup's own details and Fix. The "FAILED [n/4]" form is described for steps 1 to 3 only. Reported in #2538. Contributed by @dajiaohuang.
A Pi seat whose compaction Pi reports as cancelled now shows the compaction as cancelled, and one that failed shows it as failed with Pi's reason, instead of always reporting it done. The reason goes through the runner's existing diagnostic cleaner and is bounded to 400 characters. An event without either field still reads done. Reported in #2521 by @eisenrigbot.
The bundle reference's determinism section now matches the packer: portable mode drops machine-specific owner metadata but can keep file mode bits, so identical bytes with different modes can produce different archive hashes. The package digest compares packaged content independently of file modes and manifest metadata. Reported in #2523. Contributed by @dajiaohuang.
ROADMAP.md's "Released" section now names 0.7.8, the current release, instead of 0.7.6. Its summary is the published 0.7.8 notes' first sentence. Nothing else on the roadmap changes.
The runner conformance check (`docs/reference/runner-conformance/check-runner.mjs`) could falsely fail a correct runner on a busy machine in case 2: Ctrl-C outside a paste must send a second `session/cancel`. The fake agent ended that turn on a fixed timer from the prompt, so a runner that handled Ctrl-C more than about two seconds after the press found the turn already over, and sent nothing. The turn now waits for the cancel, within the check's existing ceiling, so a late but correct runner passes. A runner that ignores Ctrl-C still fails; a new `ctrl-c-ignored` break pins that. 0.7.8's release notes name this false failure as a known limit.
The package-tests matrix's job limit in .github/workflows/tests.yml goes from 45 to 60 minutes, for all four package legs. On 2026-11-05 a daemon package job was cancelled at 45.5 minutes on a busy runner, while others ran 33 to 35 minutes, and each such cancellation costs a full rerun. Nothing else in the workflow changes.
A first user whose Node came from a tarball or a manual install, so it isn't on the login shell's PATH, could see Codex seats report `rig: command not found`. Preflight now runs the seat's login-shell lookup for `rig` with the declared environment, through the existing bounded probe, for Codex seats only, since their tools use a login shell. When it can't confirm `rig`, it prints an advisory warning with the fix, and launch continues. getting-started.md explains putting the Node bin directory on PATH in `~/.profile` (with Bash's profile order), and checking with `bash -lc 'command -v node rig'`. Found in Quality's 0.7.8 first-user test.
Five cells in claude-compaction-enforcer.test.ts now advance the fixture clock instead of waiting on the wall clock, as #2450 and #2463 did for their neighbours. Two of them failed unrelated pull requests' daemon jobs on 2026-11-05 when the runner was busy. The assertions are unchanged; test only.
A finished Claude reply that quotes a permission question (for example "Do you want to proceed?") above the empty input box no longer makes the seat read as needing input, so a scheduled wake is no longer refused until the quote scrolls away. Above Claude's complete, empty, framed input box with its mode bar, a permission question now counts only when a numbered choice follows it, which every native permission menu draws. Real permission dialogs, including one drawn above the input box, still stop a send. A quote followed by a numbered list still reads as a dialog, as before. Found in the review of #2513.
docs/reference/daemon-http-clients.md now has a recipe for a remote client that needs people and their updates. It covers:
- the registered participants and the default person (`GET /api/gateway/participants`);
- a person's open informational updates (`GET /api/queue/open-human-updates`, with its 1-100 limit, person view and `truncated` flag);
- one rig's outbox (`outbox/list?rig=`).
It also states the limits: the default person isn't authentication, an update isn't a delivered receipt, and a person view's window can short a page. A new test drives the documented requests against the real routes.
`rig stream emit` now hands each observation to the kernel queue worker. The daemon creates an intake queue row tagged `stream-subscription` for `queue.worker`. While that row is unclaimed, later observations are appended to it as notes; once it's claimed, the next observation opens a new row. An item emitted as `critical` or with `interrupt` makes the row urgent.
The row's id derives from its first stream item, so a restart doesn't duplicate it. Items emitted while the daemon was down are caught up from a committed cursor (migration 149), retried on the next emit if a catch-up was incomplete.
**Defaults to know:**
- The new `stream.intake` setting is on by default, routed to `queue.worker`. Set it to `off` for today's behaviour, with no intake rows.
- On first start, earlier stream history isn't replayed.
- Intake rows wake their owner through the existing queue wake semantics; nothing new interrupts a seat.
The queue worker's role guidance and the `rig stream emit` help describe the routing. Refs #1856. Contributed by @Totopo27.
A remote client can now read a person's open informational updates with `GET /api/queue/open-human-updates`, without depending on Slack delivery. It reuses the existing open-update query and person-view rules, returns open updates only (not asks or delivery receipts), and takes a `limit` from 1 to 100 (other values return 400). With a person view it reads the same 1,001-row window `/api/attention` uses, filters it to that person, and sets `truncated` when more rows exist or the window was full. Refs #2487. Contributed by @dajiaohuang.
CHANGELOG.md's [0.7.8] section now says it was published on 2026-11-05 as @openrig/[email protected], built from the commit tagged v0.7.8 (12df562c), instead of "Not yet published". Its OpenShell wording now matches the published release body: the reference isn't usable in 0.7.8, and the repair ships in the next patch. The section is otherwise byte-equal to what shipped. docs/reference/whats-new.md is unchanged, because main's copy names main's package version.
OpenRig now recognises Claude Code's Theme picker (the recorded 2.1.312 layout, including its tall preview) as a current selection dialog. Its options are unnumbered, so every existing recogniser missed it, and a wake's Enter could select a theme in a person's open picker. The matcher is pinned to that layout, like the MCP and resume recognisers. A later composer or draft, quoted or partial frames, changed wording and other runtimes don't match. It recognises the dialog only; nothing is answered or dismissed. Contributed by @KM199.
A classic Claude resume whose launch subshell didn't exec Claude (because `claude` is an rc alias or function, or the launch environment correction fell back) keeps the shell as the pane label. On a slow resume, that seat could time out into attention while Claude was fine. Resume readiness now runs the existing process proof in that case without waiting for Claude's prompt: one foreground Claude naming this resume token, stable over two samples, is accepted, as a `claude` label already is, and its launched process is recorded. An idle shell, another token or an unstable process reads as before. Reported in #2508; refs #2508.
`rig send --verify` no longer reports `delivered` or `verified: true` when it can't see what the one guarded Enter did. When that Enter's recheck can't capture the pane or finds no input prompt, or the Enter's response is lost, errors or is refused, the result is unconfirmed and the command exits 1. Single and fan-out results drop the delivered claim only for the affected recipient, in human and JSON output, and `effectCheck.remedy` still tells a refusal from an unknown result. The daemon now labels a guarded Enter that failed before the key as `not_reached` with `sent: false`, and one that failed after the key as `attempted`. The one-Enter limit is unchanged, and nothing is resent. Contributed by @dajiaohuang.
A Codex seat showing a live working turn now stays `working` in the queue activity oracle when its tmux window timestamp is quiet, so a busy Codex owner isn't diagnosed as parked just because the timestamp is old. The evidence comes from the existing structural pane capture and is positive-only: a working status directly above the empty composer counts for 5 s from the first time that counter value is seen, and otherwise the elapsed counter must advance between two recent captures. A frozen counter, a stale capture or one taken before an occupant change falls through to the existing evidence. Visible needs-input stays independent, and transport's send and idle decisions are unchanged. Refs #331. Contributed by @dajiaohuang.
Regression coverage for #6: a fresh Claude session that refuses its first request because login is required reports attention_required with an explicit login reason through startup persistence, the node inventory, the nodes route and the ps projection, with no generic readiness timeout. The cases compose the real Claude adapter's delivery and readiness, startup orchestration, SQLite persistence and the nodes HTTP handler (pane text and harness launch mocked), and cover the native Claude 2.1.312 result-row form and the reply form of the refusal. Footer-only use (a gateway may authenticate) and quoted refusal text stay ready, and a Claude that never draws its TUI still ends in the generic readiness timeout. Current production code already behaves this way; nothing in the product changes.
Built and reviewed by the OpenRig Workshop team. Tests +69, no source.
When a pane is replaced while the seat attention reconciler is proving its identity, the replacement could be marked verified using the old pane's process. Before accepting Codex identity or a strict Claude resume identity, the reconciler now rechecks the pane PID and that the pane is still the session's only pane, as its Claude proof branch already does; a changed or unavailable pane leaves identity unverified. An unchanged pane verifies as before, and no new capture, loop, restart or refusal is added. Contributed by @dajiaohuang.
The CLI package build now stages `packages/daemon/assets` with Node's `fs.cp` (recursive, `verbatimSymlinks`) instead of `cp -r`. Staged assets keep their source modes, so a build run under a narrower umask than the checkout no longer packs them as 0600 or 0700 (npm keeps owner-only bits when packing). A relative link stays a link in staging and is omitted by npm, rather than being followed and copied in on macOS. Copied bytes and sources are unchanged, and a failed copy still stops the build before the staged root is recorded. Only the assets step changes; the other copy steps still use `cp -r`. Contributed by @Aummadour.
`rig walk` no longer reports a piece as lost, or as typed but not consumed, when it can't see what happened. When the sent piece doesn't appear in the record or the pane within the verification window, or the one guarded Enter's result is lost, errors or is refused, walk now says delivery is unconfirmed and to inspect the recipient before any resend. A generation change after the guarded Enter is reported separately. The walk still stops at that piece with exit 1, after one text send and at most one guarded Enter. Contributed by @dajiaohuang.
An expected-identity send's outbox receipt now reads `indeterminate` when the outcome is uncertain, instead of `delivered` or a receipt left `pending`. That covers a send whose verification came back `rendered-unconfirmed`, and one that failed or threw after input was attempted or written; an attempted or written input phase takes precedence over a `sent: false` flag. A caller deciding whether to retry is no longer misled. Failures before any input stay `failed`, and a failure while finalizing the receipt is reported as a warning. Conditional identity checks, the retained-input path and sending itself are unchanged. Limit: the plain-text CLI failure output doesn't print that warning; API and JSON output carry it. Contributed by @dajiaohuang.
An attributed broadcast's outbox receipt for a recipient now reads `indeterminate` when that recipient's text write was attempted or completed but the transport reported an error, instead of `failed`. The text or Enter may already have reached that recipient, so a failed receipt could mislead a retry decision. Delivered receipts and failures before any input are unchanged, as are the route response and the other recipients' receipts. Limit: a successful broadcast whose verification came back unconfirmed still records delivered. Contributed by @dajiaohuang.
In 16-colour mode, `rig tui`'s selected row is highlighted again. The `selection` token's 16-colour value was already a background code (`40`), and the background path adds 10, so it painted SGR `50`, which isn't a colour and terminals ignore. It's now `30`, so the highlight paints black (`40`), which keeps the row's bright-blue accent text readable and is the colour nearest the truecolor and 256-colour selection. A new cell checks that every token used as a background paints a real background code. Limit: on a dark terminal whose own background is that black, the band blends in, and the selected row is still marked by its arrow, accent colour and bold. Found in the review of #2499.
A Slack transport error no longer carries the bot token. Node's fetch rejects a token copied with a newline before connecting, and its error message quotes the whole Authorization value, which the readiness route, `rig slack verify`, the inbound and outbound paths and the inbound file download then passed on, the file download into the inbound queue item and the daemon log. `callWebApi` and `downloadPrivateFile` now remove the token and each of its lines, as sent and as Node trims them, from that message in one pass, and keep the rest, so the error still says what went wrong. Limit: a token line shorter than 6 characters, as sent or trimmed, can still appear. Found in the review of #2501, whose new directory routes already return no transport detail.
A queue item a person sent from one machine and forwarded to a seat on another now tells that seat how to answer them. The source arrives as `human@kernel@<origin-host>`, which neither message renderer recognized as a person, so the seat was told to `rig send` it, which can't reach a person. Both mirrored renderers now take the two-part person address from a host-qualified source only to recognize it, and offer the working `rig queue create` hint to the full host-qualified destination, which the queue command already splits. The sender, body, destination and origin routing are unchanged, and look-alike seats and malformed addresses behave as before. Limit: the reply still needs a route back to the origin host. Found by @dajiaohuang on #2493.
A daemon client can now offer Slack channel and person choices, to fill in a channel map or register a person, without holding the Slack bot token. `GET /api/gateway/slack/channels` returns one page of the workspace's public, unarchived channels as `{ id, name, isMember }`, and `GET /api/gateway/slack/users` one page of people as `{ id, name, realName, displayName, isBot, deleted }`, without email. Both page with Slack's cursor (`nextCursor`, null on the last page) and `limit` (default 100, at most 200), and each request is one live Slack call with no cache or stored copy. Listing needs only a resolvable bot token and config, not inbound delivery. A missing token answers 503 `slack_not_configured`, a missing Slack scope 403 `slack_missing_scope` with the scope named, a rate limit 429 `slack_rate_limited` with Slack's `Retry-After`, a transport failure 502 `slack_unreachable` with no transport detail, and any other Slack error 502 `slack_error`. Access is the same as today's gateway reads. No new scope, private channels, direct messages or migration. Limits: proven against a fake Slack, not a live workspace; an app installed from an older manifest may lack `users:read`. Reported in #2487 (item 4).
`rig tui` body text now reads on a light terminal as well as a dark one, with nothing to configure. The `bright` token, used for a slice's intent, requirements and proof text and for most plain text, and `markInk`, the codex mark's `_`, now use the terminal's own default foreground (SGR 39) in every colour mode wherever the TUI doesn't paint its own background. On a background the TUI paints itself (selected rows, the terminal mark, accent bars) they keep their fixed light ink, so those stay legible, and inverse flash rows now swap the terminal's own colours. Dark terminals look the same to the eye. Limit: in 16-colour mode the selected-row wash isn't painted, an existing issue handled separately. Reported, with the fix, by @adam-nectar.
A `local:` Claude seat now gets its current activity hooks back on a contained resume, as a `path:` seat already did, when its saved launch shows where its agent was read. The reader resolves the seat's own `local:` ref against each recorded startup-file owner root, re-anchored for shipped specs as the runtime switch reads them, counts a folder only when the ref lands on a recorded root, and accepts it only when exactly one such folder holds an `agent.yaml` with the seat's resolved spec name. Anything unproven still reads unknown: restore warns, projects nothing and never rewrites the saved context, and `rig restore-check` reads the same way. Resource origins aren't used, so a same-name import can't stand in for the seat's spec, and `builtin:` refs are unchanged. Limits: a seat whose ref can't be tied to a recorded folder still only warns, and one wrong read remains possible when a recorded root that isn't the agent's folder holds a same-name spec and the ref lands on it, for example a seat added later from another rig root with `local:.` and no startup files of its own. Builds on #2338 by @AdityaxDeore. Reported in #1530.
A daemon client can now read a rig's messages in one request: `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to that rig's seats, newest first, with the same `limit`. `?senderSession=` still returns one sender's messages, unchanged, and when both are given `senderSession` is used. Migration 148 adds an index on the outbox's dispatch time with both session columns, so the rig read walks newest first, tests the match inside the index and stops at its limit instead of scanning and sorting the whole table. On a copy of a 109,622-row outbox, a busy rig's read went from 378 ms to 0.3 ms and an empty rig's from 30 ms to 19 ms; building the index took 0.37 s. The client reference now says queue SSE events are hints to read `queue/list?rig=` again, since some events name only the destination, and that the rig queue read scans its table, so read it occasionally. Limits: a rig matches by its `@<rig>` suffix with SQLite's ASCII case folding, as `queue/list?rig=` does, and a quiet rig's read still walks the index. Reported by @ziadeh in #2487 (item 1).
A daemon client can now read the registered participants and the effective default person through `GET /api/gateway/participants`, without reading registry files on the host. It returns the same JSON as `rig gateway participants --json`, from one shared projection, so the default, policy and partial-read behaviour match. An optional `organization` query filters the participant list, while the default, organizations and groups still describe the whole registry. An unreadable participant side file still lists people from their fragments and names the error, with organizations and groups null rather than empty; an unreadable person registry returns 503 `human_registry_unavailable`. The response carries no connector bindings or secret pointers, the route uses the same access as the other gateway reads, and the default person isn't authentication. Limits: Slack channel and user reads aren't part of this; with an unreadable side file, an organization filter returns no participants, as the CLI does today. Reported in #2487 (item 4). Contributed by @dajiaohuang.
The Slack setup guide now says how to use a private channel. The generated manifest requests public-channel access only, and a new section gives the route: add groups:history and groups:read, subscribe to message.groups, reinstall the app and invite it to the channel, and optionally run rig slack setup --required-scopes so verify checks the added scopes. The channel-map paragraph says a private channel can be a target once the app has them. Direct messages stay unsupported, and the guide says OpenRig doesn't test private channels. Docs only; no behaviour change. Limits: the --required-scopes example repeats today's baseline scopes, so if the baseline gains a scope that command would need it added; a manifest option and verify mode for a private channel or direct message are not part of this change. Asked about in #313 by @ziadeh.
Someone writing a client for the daemon's HTTP API can find what they need in the reference docs instead of the source. A new page, docs/reference/daemon-http-clients.md, covers finding the daemon: rig status --json is the discovery surface, the status commands check OPENRIG_URL before any state file, start and stop read the state file first, and pid and port appear only when known. It also covers the /healthz fields and when each is present, the two bearer tokens with the routes each checks and their 401 bodies, the three event streams and their replay rules, and route details: the recent-transitions limit rule, urgent on POST /api/transport/send, the scope approve, proof judge and workflow instantiate bodies and refusals, the per-seat Claude provider usage readings (not proof that two seats share an account), and how the slice routes resolve a name two missions share. Existing pages now list every reason a send is retained, say that node inventory reads exited for a verified exited pane, say how to type into a seat without resizing it, and list the files OpenRig writes into a project, with what rig down does to each (protection is per shared file, and legacy block-only files can be removed). Docs only; no behaviour change. Limits: the API changes these reports also ask for are not part of this change; the status lookup summary doesn't name its last fallback, the configured endpoint, which rig status --json already uses. Reported in #2486 and #2487 by @ziadeh.
A queue wake from a person alias such as human@kernel or human@host now tells the agent how to reply in a way that works. Only external people got queue guidance before; an alias got a rig send hint, which fails because a person isn't a seat. The CLI and daemon envelopes now give these aliases a rig queue create update hint, with a summary and evidence reference, after the unchanged external and thread branches, so ordinary seats with similar names keep their hints and the acting identity is still derived from the route. gateway-participants.md gains a short paragraph on how people are addressed without Slack and how a client acts for them, removes two stale claims (that another registered person can't answer an ask, and that the terminal impersonates the default person), and its registration example now includes the required --delivery-class. No identity gate is added, and Slack stays optional. Reported in #2480 by @ziadeh.
Pairing two machines now tells you up front what the target needs. A daemon bound to a tailnet address starts without OPENRIG_AUTH_BEARER_TOKEN, but rig host pair hands that token to the other machine, so POST /api/hosts/pair-request answers 409 pair_target_no_bearer when it isn't set, and pair_human_required when a person must approve. The command reference and the rig host pair --help url line now say the target must run its daemon with OPENRIG_AUTH_BEARER_TOKEN set. The bearer 401 and the daemon's startup refusal named an auth.bearerToken config field that doesn't exist; the daemon reads the token only from its environment, and both messages now name OPENRIG_AUTH_BEARER_TOKEN. Text only: no change to which requests are refused. Limits: the website's multiple-machines guide is maintained separately and gets the same correction there; the shared 401 wording is still Mission Control and auth specific, so a 401 from a separately configured terminal bearer token isn't fully explained by it. Reported in #2484 by @ziadeh.
Two reference pages now match the code. checked-seat-stop.md said partialEffect true means no kill was sent; the daemon also sets it after the kill, when a selection change is seen while the stop state is saved or the save fails. The page now lists the four stopPhase values and what each says was sent, notes that selection_changed with kill_attempted means the session was killed, and says that a refusal before the stop starts carries neither field and sent nothing. sdlc-conventions.md now says that metadata.status complete is written only by POST /api/missions/:missionId/complete and is outside the readiness rollup, and lists the slice status words, the bucket each counts as in GET /api/slices, and the fallback: an unknown word counts as active, so a slice marked complete counts as active. Docs only; no behaviour change. Reported in #2483 and #2482 by @ziadeh.
When two missions each have a slice folder of the same name, the slice list (GET /api/slices) keeps only one of them, and the routes that find a slice by name serve that one, so nothing told a person the other slice existed. rig scope audit, and the daemon's audit route behind the dashboard, now add a low-severity slice_name_shared finding to each such slice, naming the other missions and suggesting a rename. It is advice: it doesn't change the audit's result or exit code, which slice the list keeps, or any route. Limits: the check looks for a same-named folder under each other mission's slices directory, which is narrower than every case the indexer can drop; renaming a slice folder unbinds rows that name the old slice; addressing a slice by mission is a separate, larger change. Reported in #2487 by @ziadeh.
rig host ls and the dashboard's host list (GET /api/hosts) now show an ssh host as reachable when ssh would reach it. OpenRig runs ssh against the host's target and leaves the rest to the person's ssh config, but the status probe dialled the target at port 22, so an ssh-config alias, or a host whose sshd is on another port, read unreachable while ssh reached it. Both probes, in the CLI and the daemon, now ask ssh -G, with the same user and target the transport uses, for the host and port ssh would connect to, and dial those. A host behind a ProxyJump or ProxyCommand reads unknown, since a direct dial can't stand for that route, as do a missing ssh binary, a timeout and a config ssh can't resolve; unreachable now means only a failed dial to the address ssh resolves. http hosts are unchanged. Limits: ssh -G makes no connection, but OpenSSH runs any Match exec command in the person's ssh config while it reads it, so with such a config each list runs that command too; ssh -G and the dial each have their own 1.5 s limit, so one host's status can take up to about 3 s, with hosts still probed in parallel. Reported in #2481 by @ziadeh.
A bundle file whose owner bits were stripped by the extraction umask, for example a script under a umask that removes the owner's own execute bit, gets its archived owner bits back after content verification. Group and other access stay as the person's umask left them, so nothing is widened. Recognized login files are capped at their archived owner read and write bits, at most 0600. Files are grouped by device and inode after extraction, the last archive header decides each one, and the login cap holds across case aliases, so on a case-insensitive volume a second name can't widen a capped login file. If a chmod fails, extraction continues and returns a named warning with the file, the failed operation and the mode actually observed, never the contents; for a login file it says the owner-only cap couldn't be applied. Those warnings now reach both bundle source resolvers, prelaunch routing, bootstrap and the install result's warning list. Content-integrity, path and link refusals are unchanged. Limits: case-insensitive aliasing is covered by simulated identity, not a native macOS run; when a login file's cap fails, the file can stay wider than 0600, with the warning saying so; Windows is skipped. Contributed by @Aummadour.
A scheduled reminder that couldn't reach its seat, for example after a reboot where the daemon starts before tmux, now stays owed and recovers instead of silently moving on to the next cycle. Each periodic occurrence, including each parked-row digest member, gets its own receipt in the existing outbox and retries through the existing scheduler and wake retry settings (default three retries, five minutes apart). A confirmed delivery is never sent again after interrupted accounting or a restart, and an uncertain one is never replayed. Exhaustion or an unconfirmed result ends only that occurrence, with its cause and next action recorded, and a recurring reminder keeps its normal cadence under a new occurrence; only a permanent transport failure ends the job. A one-shot park timer records its failed wake on its row before it ends, and a pending retry leaves its binding armed with a retry note. The fire stamp and the queue wake receipt and one-shot retirement commit in one transaction, with input outside it. Refusals are read with the transport's own rule (sent false or input not reached), and the external-CLI and interactive-prompt refusals are labelled, which the queue wake classifier, draft-aware delivery, compaction preparation, claim hints and the transport receipt also read. Abandoned occurrence claims are marked indeterminate at startup, before anything sends. Limits: scope is periodic reminders and parked-row digests, while condition watches, the parked-owner policy and explicit sends are unchanged; no schema, API, scheduler or replay of earlier failures is added, and previously recorded failures can't be reconstructed; while a retry waits, the existing one-second loop still evaluates it; a later recurring cycle is a new occurrence, not confirmation of an uncertain one.
The recorder degradation test in slow-op-recorder.test.ts is deterministic again. A zero timeout could still see a completed acknowledgement from the worker, so the test sometimes rejected a valid healthy result. The fixture now withholds the acknowledgement signal for exactly one named begin record while the real worker still writes that record, and restores normal messaging before the health checks and cleanup. Every earlier assertion is kept: the wrapped value, the begin_barrier_timeout reason, the health response and the single observation. The cell also checks the site and that the begin record reaches disk after a flush. Test only; production code and timeouts are unchanged. Contributed by @Aummadour.
The Pi tool-selection recovery guide now gives the selector example's path from the root of the Pi repository: packages/coding-agent/examples/extensions/tools.ts. The old path left out packages/coding-agent/, so a reader starting at the root couldn't find the file. Docs only. A follow-up to the review of #2186. Contributed by @dajiaohuang.
The kernel's original template now survives a daemon restart. /api/kernel/status keeps the built-in template the kernel was booted from, so rig daemon start --wait-for-kernel no longer prints variant=(none) on a lost-kernel restore, a deliberate stop, a stale kernel or a --no-kernel start. The selection is read from the rig's latest bootstrap_runs entry with source_kind rig_spec, including a partial launch, so kernels booted before this change are covered when their recorded source still matches the installation. Only the three kernel templates under the current installation's built-in specs directory are recognized. A custom project path with the same suffix, missing bootstrap history, or an old source path that can't be verified, for example after a Node version change moves the global install, reads an unknown variant, never a default. Nothing new is written: no migration, column or rig API field. The live rig spec still describes the current configuration; this value describes its origin. Reported in #21 by @tgrundtvig. Contributed by @Aschenbath.
A Slack ask now shows that it was handled when its row settles anywhere, so a channel no longer fills with asks that look open but aren't. When a posted ask is answered in a pane, closed from the CLI, superseded, canceled, resolved in Mission Control, or a person's park moves to a queue blocker, the connector edits that ask's original primary post: a status line with the outcome, actor, time and closure note, the answer hints removed, and the buttons turned into historical option text. The rest of the post and its reconcile marker are kept, and supplemental posts and separate resolution notices are unchanged. Committed queue events drive the edits, with a catch-up at start from the retained notification receipts and channel stamps. A reopened direct ask reads "Open again"; it offers a thread answer only when it has no committed answer, and otherwise says the earlier answer is retained, since reopening doesn't accept a second answer. "Asked again" doesn't claim the later ask was already posted. One serialized worker uses the recovery budget (four calls per 15 seconds): each primary normally costs one GET read of the original and one JSON chat.update, a success is recorded only when Slack acknowledges the requested message at its current revision, Retry-After holds survive a restart, transient failures back off behind other work, and permanent Slack failures are reported as not done and excluded until the next connector start. Pending edits and the last failure show in rig slack status. Limits: private-channel reads may need groups:history; refused reads and unavailable originals never count as edited; catch-up needs the original receipt and stamp and a retained queue row; the first start after upgrading edits every retained historical ended ask at the edit rate, about an hour for 450 asks; reactions are not part of this change. Reported in #2468 by @ch-davidriott.
A seat handed over on a fresh install now starts with its role, as a normal launch does. A start types each send_text startup file into the new conversation, the role first; a handover ran no startup delivery, so a fresh or rebuild successor of the built-in development, design and conveyor agents and vault-specialist started without its role file, and nothing said so. The first section of the turn each fresh or rebuild successor already gets, the restore packet or the priming packet, now lists the seat's send_text startup files as absolute paths, role first, before the predecessor's context: the files a fresh start delivers, required and optional. A file whose source is missing is listed as MISSING, and a missing required one joins startedWithoutRequired, so the seat reads as needing attention in rig ps with the file named, and the handover still completes. Fork successors, which carry their parent's conversation, and discovered successors get no list. The runtime switch is unchanged, since its successor's first turn already carries these files' text. Limits: a pointer asks the successor to read the file, rather than pasting its text as a start does; send_text startup actions, such as an after_ready preload, aren't run for a successor. Reported in #2456 by @eisenbergjerry.
An idle Claude seat now reads idle when Claude Code draws newer rows below its mode footer: a token count, the versioned "current: … · latest: … ✔ Update installed · Restart to update" banner, and /rc. The footer search already skipped known status rows such as the bare "Update installed · Restart to apply" banner and the /clear hint; it now skips these three exact shapes too, so an empty prompt above them is found. So rig compact goes through, managed compaction sends its /compact, and a send that waits for idle sends. An unrecognized trailing row still reads unknown, live work stays active, and a typed draft is never idle. This covers the layout reported on November 3; #1641 stays open for the parts it doesn't cover, such as the /clear hint wrapping onto a second line. Reported by @aanestesia in #1641. Contributed by @Nikhila-Dhevi-R.
A coordinator now sees in the seat list when a handover's successor started without a file it requires, and the person who ran the handover sees it in the same turn. An ordinary start fails when a required startup file can't be delivered; a handover's successor started anyway, and the missing file was only a note. The handover now names each required file the guidance file should hold that its successor started without: a projected guidance file, or a guidance startup file marked required, including when an optional startup file shares its block id. The list is on the result as startedWithoutRequired and on the seat.handover_completed event with the successor's session id. needsAttention reads that event while the successor's session is the seat's current one, serving the full paths as evidence; the reason column reads "missing required <file name>". The handover prints "Seat handover complete, degraded: <seat>" and names each file. The mark clears when the seat starts a new session, never by time. The handover still completes, with no new refusal, table or state. Limits: an exact resume clears the mark even if it couldn't put the file back, and its own result names that gap; a discovered successor isn't checked; a missing launch addition doesn't mark the seat; a required send_text startup file, such as an agent's role, isn't delivered to a handover's successor and isn't checked; repairing the file in place doesn't clear the mark before the seat's next session. Reported in #2456 by @eisenbergjerry; #2456 stays open for delivering those startup files.
An edit to a culture or startup file now reaches the next process OpenRig starts for a seat: a launch, an exact resume, a handover or a runtime switch. A managed block a person edited by hand is left in place and named, instead of being silently replaced or left stale. OpenRig records a hash of what it last wrote in each managed block, keyed by the guidance file's real path (so every link to one file shares it) and the block id, in a new managed_block_writes table. Every adapter's guidance merge and the resume and handover put-back decide through the same write. A block OpenRig wrote is refreshed from its changed source. An edited block is kept, with a guidance_block_edited warning. A block written before this change has no record: a start or a runtime switch replaces it if it differs from its source, as before, and names it; an exact resume or a handover keeps it and names it as possibly stale. A record that can't be read is treated as unknown and the differing block is kept; a record that can't be saved is named, and the write still counts. A Codex start on launch developer instructions refreshes the blocks already in AGENTS.md and adds none. When a block is left as it is, the seat is told in one line after a start, in a runtime switch's successor first turn, and in a fresh or rebuild handover's packet; an exact resume types nothing and names the block in its result. Limits: the record is per file and block id, so two rigs whose blocks share an id in one file share it; a writer outside the adapters' guidance merge and the put-back isn't covered, and none writes managed guidance blocks today. Reported in #2456 by @eisenbergjerry; #2456 stays open for its remaining handover work.
The daemon's burst test now also covers a burst of seat inventory reads. The existing N=24 real-HTTP cell in ps-summary-stall-red.test.ts runs a second case. A child daemon on the host-shaped fixture with today's migrations serves 24 /api/rigs/:rigId/nodes requests across its active rigs while health and an exact queue read are sampled. Every inventory reply must be non-empty and for its own rig, and the worst health and queue reads must stay under the shipped 250 ms responsiveness budget. In the run that added it, the worst reads were about 117 ms and 124 ms, against 6 ms and 9 ms without the burst. Only one paired probe completed during the burst, so this guards against a regression rather than measuring a distribution. The health route is the fixture's event-loop stub, not daemon health. The ps+summary case is unchanged. Test only; production is unchanged.
A Claude member in a rig spec can now register just that agent for plugin channel notifications with plugin_channels, a list of plugin:<name>@<marketplace> entries. Every launch of that agent (fresh, resume, fork, restore and handover, in both the pane-shell and managed forms) passes one --channels followed by the entries, after its other options; an agent without the field gets none, and nothing is added for other agents or runtimes. The spec refuses a server: entry, a malformed or repeated entry, a non-list, and the field on a non-Claude member. A non-empty list beside claude_mcp: none produces a schema advisory, since the empty strict MCP configuration excludes plugin servers: it names both remedies, to remove claude_mcp: none to use the channels or remove plugin_channels to keep the MCP opt-out, and both settings remain and the member launches normally. The pane-shell form now writes each launch flag followed by its quoted values, so a multi-value option keeps its values. The entry grammar comes from Claude Code 2.1.316's release binary; no real channel plugin was run. Requested in #2044. Contributed by @Aschenbath, carrying on @todie's fork work.
Blank filler panes in tiled grid layouts (Herdr and Rex) now open the account's own shell as a login shell, <shell> -l, instead of a hard-coded sh. The shell comes from the account's passwd entry, then $SHELL, then /bin/sh, and only an absolute, executable path is used, so a failed or stale lookup never stops a layout; the passwd entry comes first because a long-running daemon can inherit a $SHELL that's gone stale. A filler now behaves like a new terminal of yours, startup files included, so an rc line that starts or attaches tmux runs in each filler too. An account whose shell refuses a session, such as nologin, closes its filler panes rather than falling back. Remote tiles are unchanged. Contributed by @natefaerber.
A freshly launched seat opened only through writable tiles is now sized to the first tile instead of staying at its 80x24 creation size. Every writable tile attached with tmux's -f ignore-size, and tmux ignores an ignore-size client whenever any client of any session sizes its window, so on a server with another sized session no tile sized the new seat. A writable tile now checks its session's clients just before each attach and keeps -f ignore-size only when one of them already sizes the session; otherwise it attaches without the flag and sizes the window. Read-only tiles keep -r unchanged, and the viewer accepts either writable spelling. Two tiles attaching at once both size, under the session's window-size option, like any two plain clients, and a tile that attached with the flag keeps it until its next attach if the sizing client later leaves. Contributed by @natefaerber.
An accepted re-estimate can now move a claimed row's deadline without unclaiming the work: rig queue update <id> --closure-required-at <ISO>. The existing update transaction records the old deadline, the new one and the accepting actor alongside the note; the header-derived actor wins when present, and without a header the existing body-only attribution applies unchanged. The overdue query reads the revised value at once, and the next stuck sweep closes an already-open overdue finding while the work stays claimed, unless an independent pickup or stall recovery still shares custody. A deadline that passes after an earlier recovery for the row was closed is reported again, whether or not the owner is reporting activity: the renewal follows the row's own expired deadline rather than which finding kind wins, and the kind stays as today, so a row that is both stalled and overdue can report as stalled-after-claim. Open recoveries keep custody, and only a renewed expiry gets the deadline in its recovery ID. Limits: the field accepts a deadline on an in-progress row and doesn't read or backfill ETA prose; other pickup and stall findings stay independent; on 0.7.6 and 0.7.7, unclaim followed by claim remains the workaround and resets the claim. Reported in #2453 by @eisenbergjerry.
The concurrent same-name import cells in yaml-import-replaces-stopped-generation.test.ts are deterministic again. Each cell starts a first import and waited, with vi.waitFor's default one-second poll, for it to reach a held probe or prelaunch hook. The import reaches that hold only after preflight's profile-resolution worker answers, which under vitest loads tsx first, so on a loaded runner the poll could give up with the import still on its way. Each held stub now releases an entered gate, and the cell awaits it raced against the first import settling, so it waits as long as the import takes, within the package's test timeout, and an import that settles before its hold fails at once. Every assertion about the refusal, the single replacement and the rollback is kept. Test only; production is unchanged.
The instance layout reference now says which directories the spec library scan skips. Below the built-in specs, $OPENRIG_HOME/specs and the legacy library, it skips any directory named .git, .worktrees, node_modules, dist, build, .turbo or .next (SKIP_DIRS in spec-library-service.ts), so a spec such as specs/build/rig.yaml isn't listed and nothing says why. To list it, rename its directory and run rig specs sync; rig specs add keeps a directory's own name, so it doesn't help for a directory named build, and rig up with a path to the rig.yaml still launches it without the library. Text only; the scan itself is unchanged. Reported in #2333 by @EZotoff.
A handover now puts a seat's missing managed guidance blocks back for every runtime before its successor launches, as it already did for Codex and as exact resume does. It passes the rig's Claude file, so a Claude seat on a rig that uses CLAUDE.local.md gets its blocks there, not in CLAUDE.md, and the file each seat gets still follows the runtime saved at its last launch. That covers a rebuild into a working directory without the seat's blocks, for example after rig seat set-cwd. Blocks already present are left alone, and a block that can't be put back is named in the result's notes while the handover completes. Not covered: comparing blocks already in the file with their current source, on any route, is not part of this change; a required startup file that can't be delivered fails an ordinary start but is only a note on a handover; Pi, omp and a Claude fork take the same call, and the new cells cover Claude fresh and rebuild. Reported in #2456 by @eisenbergjerry.
An agent's page in rig tui keeps its RECENTLY FINISHED section however busy the rest of the fleet is. The page used to read the instance's 20 most recently finished items and filter them down to the seat, so 20 finishes elsewhere could hide everything this seat had just done. It now reads GET /api/queue/list with state done and handed-off, sorted by update, limited to 20 and filtered to the seat's own session, which the route applies in one query. The Pulse JUST FINISHED lane keeps its instance-wide read. While a pending drill has every rig's inventory loaded, a seat whose logical ID another rig also uses can show an empty section for that one refresh; it never shows another seat's rows. A failed read shows no section and names the read, as before. Contributed by @Nikhi00718.
The spec handed to a seat's startup layering now carries only the rig layers that exist: withRigStartupLayers adds cultureFile and startup only when the stored or declared layer has a value, so a missing layer is no key rather than one holding undefined. The layers still replace the spec's own pair, as before, and OpenRig's own config resolution already ignored the undefined values, so seats launch with the same guidance; callers that inspect the spec's keys now see exactly the layers there are. The rigStartupLayers comment now states the rule as it is: the culture file and startup block are decided as a pair. Reported in #2454 and #2455 by @eisenbergjerry.
A stalled tmux client no longer holds a seat's later sends or the daemon's single-flight captures indefinitely. The default argv executor and the POSIX legacy executor now end a tmux client that hasn't answered within 30 seconds, using execFile's own timeout with SIGKILL, so the call fails only once the client is gone and the seat's next operation can run. Only tmux invocations replace the shell, so kill keeps the shell's builtin, including on hosts without an external kill executable. A deadline failure logs the tmux operation and its target without input payloads, and its error tells the person that the write may still have happened, to check with rig capture before resending. There's no new state, refusal or error code. The shared execCommand is unchanged, including RuntimeVerifier's separate tmux -V and cmux probes. Windows argv calls are bounded; legacy Windows strings and injected executors are unchanged. The POSIX legacy path's output limit grows from exec's 1 MiB to the argv path's 64 MiB. Not covered: this is a per-exec deadline, so cleanup can require another bounded call, and killing a client doesn't establish whether a server-side write happened.
Operators can set specs.builtins_visible to false to leave built-in rigs, agents and workflows out of the shared spec inventory that the CLI and TUI list; the default stays visible. rig specs ls --include-builtins lists them on demand, and explicit lookups and launch paths still reach them, so this is an inventory preference, not an access boundary. With built-ins hidden, a visible user spec wins a name lookup; with both visible, a same-name match stays ambiguous, as today, and rig specs ls --include-builtins --json shows each entry's explicit ID. A user's copy of a workflow keeps its own source-specific library entry, projected only while the cached row for that name and version holds the file's current content, so an older version can't take its link. Requested in #2333 by @EZotoff. Contributed by @Nikhila-Dhevi-R.
A client, such as an outside GUI, can now settle exactly the decision its person reviewed. GET /api/queue/:qitemId returns decisionRevision, an opaque token computed on read over the question (body, intent, questions and options) and the decision's state (open, blocked or terminal, who it's addressed to or parked on, the recorded answers, a delegated decision, the current park and the replies returned). A summary change, a wake or a claim doesn't move it; a new park does. POST /api/mission-control/action takes an optional expectedRevision for answer, resolve, approve and deny. When it's current, the act runs as today and the result adds the saved revision. When it's stale, the response is 409 decision_revision_changed with nothing written, carrying the current revision, state, answers and most recent action. On other verbs the revision isn't checked, and the result's revisionNote says so. The compare runs at the start of the act, before any branch that writes, with no await before the first write; a runner's permission ask whose call wrote nothing before its await compares again just before its close. A retry under the same operationId in auditNotes, with the same verb, sender and payload, is reconciled from its record (reconciled: true, the original actionId). A forward to the origin host carries the revision. It's opt-in: a caller that sends no revision acts exactly as before, with the same result fields, and the CLI, TUI and Slack don't send one. Not covered: the Mission Control views and attention reads don't carry the token yet; an exact retry of an operation that changed nothing acts again and writes a second audit row; a forwarded act still runs as this daemon's identity at the origin. Requested in #2446 by @pvl-agent.
Bootstrap's runtime verification no longer waits forever when Pi is installed but node --version hangs. The Node check now runs through the same bounded probe executor as the other runtime probes, so a timeout is recorded as an error with its message, as a hanging binary probe already is, and a shutdown stop propagates without persisting a misleading result. Other Node lookup failures, such as node missing from PATH, keep the existing fallback, and the engine floor is still checked again at launch. Contributed by @Vaishnavi220506.
On a draft-aware seat, a wake no longer leaves its own paste in the input and then holds every later delivery behind it. When the guard can't read the input just after its own paste, it reads once more after 300 ms; an input that still can't be read is refused as before, and unknown never counts as empty. When it does refuse, it records the paste as OpenRig's. The next delivery that finds exactly that paste (the same occupant and pane, no input from a person through OpenRig since, and the paste's own text or the collapsed label recorded right after pasting it) submits it first, then pastes its own text, and says so in its result. The final checks run again just before that Enter. Nothing is ever removed from the input, and a delivery that waits for an idle seat, such as managed compaction, stays held. Not covered: the 300 ms bound isn't measured on a native Claude; a label first seen after the delivery ended stays held; a person's replacement paste of the same length with a reused label isn't told apart; a read-only tmux client refuses the leftover's Enter, so the next wake stays held. Reported in #2429 by @billbenetti.
Step 4 of the openrig-upgrade skill now works on a host without the sqlite3 CLI when the running Node provides node:sqlite. backup-sqlite.mjs still uses the sqlite3 CLI's .backup first (OPENRIG_SQLITE_BIN, or sqlite3 on PATH); when the CLI isn't there, it uses Node's built-in node:sqlite online backup() for a page-for-page copy, and VACUUM INTO only when backup() isn't available, then checks the copy with PRAGMA integrity_check. With neither the CLI nor node:sqlite, it stops with a message naming both remedies. It needs no extra dependency. A focused test runs the helper with an empty PATH, confirms sqlite3 can't be found, and checks the copied database. Reported in #2391 by @ziadeh. Contributed by @mohitdixit02.
The armed-map cells in compaction-preparation.test.ts are deterministic again. The fixture's injected clock and transport sleep advance fake time, but waitForIdle's deadline read the real Date.now, so with #2445's longer managed /compact wait an attempt could expire on fake time before the second idle poll, intermittently on a loaded runner. The four cells now spy Date.now onto the fixture clock, as other cells in the file already do, and assert the busy wait stays within the readable budget. Test only; production is unchanged.
Writing the experiment config no longer fails on a platform where Node has no process.getuid. The owner check compared the file's uid with process.getuid?.(), which is undefined there, so every write was refused as "experiment config must be an owned regular file". The comparison now runs only where getuid exists, so POSIX behaviour is unchanged, and the regular-file and single-link checks still apply everywhere. Contributed by @Aschenbath.
Managed compaction now works on a small Claude pane, such as a tile in a follow view. The /compact send's readiness probe and its final input check read the pane at a readable size, using the existing readable capture, which pins the window, waits for the redraw and restores the size exactly; the uncertain post-compact read does too. The managed send's idle wait includes the readable budget, still bounded by the attempt's deadline, so the larger read can finish. Ordinary sends are unchanged, and a pane that still can't be classified stays unknown, never idle. An ended attempt now records its reason in COMPACTION-STATE.json. Reported by the Ops team on an 11x20 pane.
A new scenario, packages/test-system/scenarios/queue-transitions-reach-exact-states.yaml, drives the queue transitions the test-layers Help wanted row lists as still owed: handoff, block, resolve and unclaim. It runs through the existing env.queue fixture and the shipped rig queue commands, and asserts each row's observable state through expect queue, which now reads closed rows too (rig queue list --json --full --all): the handoff's source reads handed-off to its new owner, and its successor is pending there with handedOffFrom set. Invalid fixtures are refused when the scenario loads, including a whitespace-only handoff destination. Test harness and docs only; no product code. The scenario itself hasn't been run end to end yet. Contributed by @DeryFerd.
An agent that takes over a seat is now told about the seat's held messages, as a seat that starts already is. After a fresh, fork, rebuild or discovered handover, or a runtime switch, the result's warnings name how many messages are held and `rig seat held-messages <seat>`, and `rig handover` prints them. The notice is also typed to the new occupant, except into a discovered successor (a session OpenRig didn't launch) or after a restore packet that wasn't confirmed, where it could submit someone's unsent text. A runtime switch's activation carries it, once per activation attempt. Nothing is replayed. A full held quota no longer rolls back mission control's route and handoff verbs or a workflow step's handoff: the wake is recorded as not retained and the work still moves, as `rig queue handoff` already does. Reported in #2430 and #2431 by @billbenetti.
A Codex seat that hands over to a new occupant keeps OpenRig's guidance. Since #2441, a fresh Codex launch carries the guidance as launch developer instructions instead of writing AGENTS.md, but a fresh or rebuild handover successor launches without them, so it started with no guidance (a fork very likely too; the put-back covers it). Before the successor launches, the handover now puts the seat's missing guidance blocks back in the repository AGENTS.md from its saved startup context, using the same steps restore uses: only missing blocks are written, the person's text around them is unchanged, and a seat that already has them, or has no saved context, is left alone. A discovered handover launches nothing and is skipped. The startup guide lists a handover successor among the cases that still use the repository AGENTS.md. Part of #875, which stays open.
docs/reference/agent-startup-guide.md gains a "Codex guidance at launch" section. On a fresh launch of Codex 0.164.0 or later, with no developer_instructions in a config layer OpenRig reads, a seat's managed guidance is passed as launch developer instructions, and neither the tracked AGENTS.md nor the Codex home is written. The section lists the layers read and the home used, and when the repository AGENTS.md is still used: a configured or unreadable layer, a resume or fork, a restore (even one that starts fresh), and an older or unknown Codex. If your shell alone sets CODEX_HOME, declare that same absolute path in the member's or rig's env:, so OpenRig checks it and a configured value keeps the repository file. Packaged, MDM and enterprise-managed settings aren't read and may be replaced, and legacy MDM config can override the guidance. The delivery table and the Pi and OMP lines now match where their guidance goes. Part of #875, which stays open.
`rig seat set-typing-guard` now works from an ordinary terminal. Without a seat identity, a valid request applies, and the preference and its audit entry record the actor as `unattributed (no seat identity)`. With one, attribution is unchanged, and neither the request body's actor nor the target seat's name stands in for it. Target, mode and reason validation, activation, pending changes and held messages are unchanged, and there's no migration. The command's help and docs/reference/seat-delivery.md describe both cases. Reported in #2350 by @Aschenbath.
A fresh Codex seat's managed guidance now arrives as a launch developer instruction instead of being written into the person's tracked AGENTS.md. On a fresh launch with Codex 0.164.0 or newer and no developer_instructions configured in any layer, OpenRig passes the guidance with -c developer_instructions, and the guidance tells Codex to follow AGENTS.md where they conflict. With no Codex home declared, it reads ~/.codex. Resume, fork, older or unknown Codex versions, a configured value, and a CODEX_HOME set only in a shell profile keep today's repository file. Part of #875, which stays open.
A long one-line delivery to a Claude seat is now submitted when Claude wraps it short of the input box's width. The check that the input holds only the delivery's own text accepts Claude's wrap points, up to 6 columns short, and each row must still match exactly. Codex is unchanged. Reported in #2429 by @billbenetti.
A seat that starts with held messages is now told about them once: it gets one notice saying how many were kept and not delivered, and to read them with `rig seat held-messages <seat>`. The same line appears in the launch's warnings. Nothing is replayed. Handover and runtime-switch successors don't reach this point yet, and a follow-up gives them the same notice. Reported in #2431 by @billbenetti.
A watchdog job now holds at most one reminder per seat: holding a newer reminder retires that job's earlier held one, inside the retention transaction and before the capacity check, so it frees the slot even at a full quota. A queue handoff (`rig queue handoff`, and handoff-and-complete) is no longer blocked by the recipient's full held quota: it stages its wake in a savepoint, so a full quota records that the wake wasn't retained on the successor and the handoff commits, while any other staging failure still rolls it back. That deliberately changes "one act or none" for this one failure. Mission control's route and handoff verbs and workflow step projections still roll back on a full quota; a follow-up gives them the same savepoint. Reported in #2430 by @billbenetti.
`rig scope` now gives each slice one standing and counts it once. A slice in `closed/` is closed; one whose status is shipped* or done is shipped; one whose status is closed* or superseded, or whose stage is superseded or retired, is closed; everything else is active, blocked included. `slice ls --state` filters on that standing (the default stays `active`, and the help says what each value means), and `mission ls` and `mission show` count by it, with a new `shipped=` count, so a mission whose slices all shipped no longer reads as all active. A malformed slice file reads as empty frontmatter instead of throwing. The advisory dependency graph reads the active set, so it no longer offers a superseded slice as ready to build. The daemon's copy of the scope types matches the CLI's.
Reported in #2427; thanks to @gifflen.
A Claude seat no longer loads openrig-core's skills twice. Each seat's own `openrig` skill plugin received a copy of every selected plugin's skills, and the seat also loads the vendored `openrig-core` plugin directly for its hooks, which includes its skills. The copy is now skipped exactly when that direct load applies (a Claude seat, the vendored manifest present, and a selection that is `<plugins>/openrig-core`). Both routes reach Claude through `CLAUDE_CODE_PLUGIN_DIRS`, so no skill is lost. Existing seats change at their next fresh launch. Codex is unchanged.
Thanks to @billbenetti for the report.
The draft-aware typing guard's input reader treated a Claude prompt suggestion (ghost text) as a person's draft unless every cell, spaces included, was dim, so a multi-word suggestion with plain spaces held every send and wake. It now uses the rule `rig capture`'s classifier already applies: a cursor-to-end suffix is a suggestion when every non-space character is dim, and any undimmed word keeps it a draft. Text before the cursor stays authored input. The test uses a retained Claude 2.1.296 frame with synthetic dim styling, not a capture of the reporter's 2.1.316 session; grey text without SGR dim is outside this change.
Reported in #2428 by @billbenetti.
A handover whose source OpenRig launches (fresh, fork or rebuild) records its successor session as launched, in the same transaction that registers it, as a runtime switch already does. `rig seat stop`, `restart` and `clean` then treat the seat like any seat OpenRig launched, instead of refusing it as "adopted, not launched by OpenRig". A discovered-source handover, and genuine `claim`, `adopt` and `attach` sessions, stay adopted and are still refused. Tenure, the reserved occupant generation, predecessor supersession and events are unchanged. Seats handed over before this keep their adopted origin until their next launched handover or managed launch.
A Pi seat's managed guidance now goes to its own agent folder (`PI_CODING_AGENT_DIR`'s `AGENTS.md`), so a person's tracked repository `AGENTS.md` stays byte-identical across launch, `rig down`, restore and exact resume. A fresh launch asks the adapter once and records the answer in a new nullable `node_startup_context.guidance_file` (migration 146); restore, the exact-resume put-back, teardown, inventory and snapshots all read that one target through `seatGuidanceFile`. Rows and snapshots recorded before this keep the repository file. An exact restore of an older snapshot records that snapshot's choice, and a symlink or hard link introduced after launch is reported as a gap rather than written through. Codex, OMP and Claude are unchanged; this does not stop Codex's tracked-file writes, and #875 stays open.
A bounded `rig queue show` body now ends with `…[cut at N of M chars; rig queue show '<id>' --full]`, so a reader that keeps only `.body` can see that more text exists and how to get it. Counts are code points, like the preview; the row ID is shell-quoted. `bodyTruncated`, `bodyBytes`, `readView`, the stderr notice and `--full` are unchanged, and short or empty bodies are untouched.
Thanks to @genius8267.
A click on a re-parked Slack ask records the person's new choice for that park and wakes the agent, instead of silently resuming it with the person's earlier answer. The click branch looks up the current park's answer, as typed replies already do; a direct answer to a blocked ask, typed or clicked, carries the park's episode key, so a re-park's click gets its own answer row, which is the owner's wake; and a complete stored set is final only for a pending ask, or for a park this person has already answered, with other questions' answers carried forward as on a typed re-park. The unused typedReply input flag is removed. The committed-answer guard for another person's answer to the current park and the winner check are unchanged, and a pending ask's clicks behave as before.
Source +11/-7 across three files; the pinned re-park click test now expects the new answer, plus one cell for a re-park over a complete set.
For an ask to, or a park on, a registered person with no connector binding, the Feed's detail now reads "Slack: not sent: this person has no connector binding, so they're asked on Steering and in the Feed only", and its route line says "answer on Steering; it wasn't sent to Slack", the wording quiet asks already use. A real Slack receipt still takes precedence, and a Slack-bound person's wording is unchanged. Wording and row selection only, local to the attention route.
routes/attention.ts +16/-5; one test cell.
A rebuild handover's receipt now says what happened. sideEffects.provenanceRecordWritten is measured: commit() reads the node's occupant tenure before and after recording the handover in its own transaction, and reports true when a new handover-kind tenure exists for the reserved generation; a swallowed mint failure reads false. A new sideEffects.departingProcess reports ended-graceful, ended-forced or not-touched, from the in-place termination the successor launch already performs. departingSessionKilled stays false, which is accurate: the tmux session is kept and the pane respawns. The CLI's human lines and the seat-continuity-and-handover skill now say whether the lineage record was written, how the old process ended, and that a bound seat whose record is missing shouldn't be handed over again.
7 files, +125/-20, most of it tests.
An environment seat's startup identity, selected role and context references, and text actions travel to the guest through a hash-checked first-input file, with a bounded initial prompt pointing to it; nothing is typed through the host viewer, so startup text can't reach the host shell if the viewer exits. Readiness is the guest execution's identity, generation and running state; only environment connect execs; the viewer does one live identity and generation lookup (driver 30 s, client 35 s) instead of serial file validation, while launch and the guest latch still validate. A failed owned delete reports its observed stage and error and whether dispatch is unknown, keeping the original intent, claim and same-operation reconciliation. An older guest helper's rejection of first input names the likely outdated image and the rebuild step.
Limits: a same-generation reconciled launch opens no viewer, and a non-text interactive startup action on an environment seat fails without host fallback.
313 lines added and 18 removed across 15 files, about two thirds of them tests.
The dsh example runner reads DSH_SEAT_BIN and DSH_SEAT_HOME, which a member's env: can set (OPENRIG_* stays reserved for OpenRig's own per-launch values and is still read first when exported). It doesn't read dsh's own DSH_HOME: a seat's shell runs the person's startup files after the seat's env:, so an exported DSH_HOME would rewrite their personal dsh profile and skip the uploads-off patch; any inherited DSH_HOME is still replaced with the seat's home. The launch record's producer takes dsh's version from dsh --version, killed with SIGKILL at a 5-second timeout so a wrapper that ignores SIGTERM can't hold the seat's start, falling back to the handshake's version, labelled as such. Not verified: a real dsh's --version output and whether it makes a network call.
Runner +23/-6; README, one preset limit line and the dsh example test.
Any registered person in a shared workspace settles another person's ask as themselves, from the Feed, Steering, the TUI or Slack, and the record keeps who answered whose ask. The first committed answer is the answer on every surface and in either order: a later answer from someone else gets 409 qitem_already_terminal with answeredBy and records nothing, a click honours an answer already committed (on a pending ask or a park's current question), an interrupted close finishes as the first answerer, and a typed reply routed to a person who isn't the asker stays correspondence. A person may have no connector binding (rig gateway human add without --binding): they load through every registry reader, are reached in the Feed and Steering only, get no Slack post, and their asks never read never-posted or undelivered. The terminal's primary-user act is recorded as human@host, unattributed, whoever is registered.
Known limit: a click's option written before its answer row (and, on an ask with several questions, partial clicks completed or closed by another person) can stand beside or inside the closer's answer without the first clicker's authorship.
Source +210/-90, tests +455/-119.
The kernel hook-review reader now recognises a managed Codex seat whose pane reports the launcher shell: OpenRig runs managed Codex through a staged `/bin/sh` script, so the pane command reads `sh` and the reader's `codex` check never passed. For a non-`codex` pane it now requires the existing `verifyCodexPaneProcess` proof before and after the screen capture, with the same pane PID and lineage fingerprint, and keeps every binding, session, pane and dead check; an unverified chain stays unknown and neither raises nor clears an escalation. The direct `codex` path is unchanged. Nothing approves, edits or trusts hooks. The scheduler's tmux dependency gains `getPanePid`. Source proof, not a native pass.
Daemon +14/-5, tests +65.
For You shows Approve only on an ask with no structured questions, or a runner permission request, where a close means permitted. For an ask with questions it says to answer in the TUI (`rig tui`) or tell the agent in Chat, so the request stays open; an ask from a remote host names that host and doesn't suggest Chat. "Has questions" is a non-empty `humanQuestions`, the rule the daemon's attention view already uses, and the card's payload now carries the ask's questions and tags from the start. This is a UI guard only: the action API still accepts approve.
Reported in #2406. Contributed by @w4rc0n. UI +29/-7, tests +86/-1.
The web UI's maintenance notice now renders in its own row above the header rather than over it, so the header's name, host label and Theme control are visible on first visit; dismissing it gives the space back. The notice and its existing dismissal storage move out of `App.tsx` into `UiMaintenanceNotice`, and the shell, explorer, shared detail drawer and proof image viewer take the notice's height into account on phones, and the phone rail's links stay above the bottom navigation and scroll.
Contributed by @w4rc0n. UI +68/-68, tests +38/-6.
The Dashboard's kernel card uses the UI's surface token instead of a fixed white background, so it follows the dark theme and its heading and status text stay readable.
Contributed by @w4rc0n. UI +3/-3, tests +9.
The Topology table no longer auto-resets its page index when its data changes (`autoResetPageIndex: false`). The table has no pagination, and each reset queued a state change that re-rendered, rebuilt the data and reset again, so one keypress in "Filter agents" kept the tab busy. Filtering, clearing and sorting while filtered now settle. A regression test types into the filter and checks that it settles.
Contributed by @w4rc0n. UI +3, tests +44.
A manual `rig compact` whose idle wait expires now reports `map_complete_seat_never_idle` when a completed restore map exists and `preparation_incomplete` when no completed map remains, matching the automatic preparation-expiry path. Other transport failures, the idle requirement, the timeout budget and command delivery are unchanged. HTTP-route tests cover a complete map, an absent map (the published and `.tmp` paths both removed) and a non-timeout refusal.
Credit to Seán McNamara (@seanmcn) for the timeout-reason mapping in his fork PR #3. Enforcer +5/-1, tests +28.
The OpenShell README's actor-attribution example now uses `operator-agent@kernel`, the kernel operator's actual address (as the Slack CLI's default already names it), in place of `operator@kernel`, which isn't a kernel seat. The surrounding instruction to use the actual acting member's address is unchanged.
Docs +1/-1.
Fact-only corrections so the in-repo docs and `--help` match what 0.7.8 ships: the CLI reference gains the `rig environment` section and the `host edit`, `host remove` and `terminal attach` rows; both contracts link the in-tree presets; the guest listener's permission routes, port and host defaults are stated as they are; status labels say what 0.7.8 ships for grants, the answered permission path and `rig environment`, keeping "no harness end to end" and "native verdict pending"; `rig provider switch --help` says a stopped seat isn't relaunched (it prints the `rig launch` that resumes it) and that the switch asks to keep the running effort, using the saved effort when it can't, unless the seat chose its own; `rig host` help says add, edit and remove rewrite hosts.yaml from the known fields, dropping comments and unknown keys; the capability map's verb count reads 91; and `/abort` and the shipped dsh preset's resume behaviour are described as they are. Section 7's text is unchanged.
Docs and help text +70/-28; no behaviour change.
A newly created default kernel starts in `<workspace.root>/kernel` instead of `workspace.root`, through both creation paths (daemon start and the TUI's first setup), so a Claude kernel seat's managed `CLAUDE.md` no longer lands above the default projects folder and isn't loaded by every project seat. If the folder can't be prepared, the kernel falls back to `workspace.root` with a warning; nothing refuses a boot. Existing kernels keep their stored working folder and restored conversations; nothing is moved or cleaned up. The operator role's working-directory line follows the new folder, and `docs/reference/agent-startup-guide.md` explains the optional move for existing users: a fresh launch starts a new native conversation, custom text in a shared `CLAUDE.md` is kept, and step 3 waits until no running rig's seat still works in the old folder, as `rig down` checks.
Reported by @ch-davidriott in #2390.
Daemon +24/-11, guidance and docs +28/-1, tests +59/-6.
An assistant connected through `rig mcp serve --http --grant-service` can now claim, update and park the rows it owns and see its team, with the scope that show, list and create already apply to external callers: claim and update responses go through `projectForExternal`, so a blocker, closure target or waiting detail the caller can't read stays out; an external owner needs both the exact destination and the existing item scope, so a known id outside the grant gets `out_of_scope` and stays unchanged; an external park must name a `blockedOn` the caller can read, so a bare re-park can't restore or reveal the blocker saved at claim; and `rig_team`'s `lastWake` for a hosted member counts only rows in the caller's item scope. Local and token-grant behaviour and the error codes are unchanged; refusals use the existing `out_of_scope`. `docs/reference/hosted-members.md` describes the loop.
MCP server +51/-16, docs +32, tests +329.
Pi's stream heartbeat (#2336, by @porterchild: one 5 s tick, a beat once 5 s pass since the runner's last post, stopping when streaming clears or the runner exits) now posts the runner contract's working beat (section 4): `active` / `liveness` with the open run's `turnId` and `livenessSeconds: 30`, in place of `active` / `streaming`. The Pi runner now posts `UserPromptSubmit` / `accepted` with the next `turnId` for each prompt it gives Pi: a prompt or `/followup` sent into an open run is posted as it's sent, and a prompt sent while idle is posted when its run opens, so a command Pi handles without a run (such as `/model`) opens no turn. `/abort` opens no turn, and OMP is unchanged.
Named limit: after a daemon restart in the middle of a run, the run's beats are ignored until the next prompt.
Pi runner +30/-4, tests +145/-19.
docs/reference/slack-app-setup.md's channel-map section said missed-message recovery covers the default channel only. Since #1536 it recovers the default channel and every mapped channel, except a channel relayed to another machine, as the guide's reconnect-recovery sections already say. The historical 0.6.12 release note is unchanged.
Docs +1/-1. Reported by @ziadeh.
The dsh example runner now posts the runner contract's working beat (section 4): while a turn runs and nothing waits on a person, it posts `active` / `liveness` every 10 s with the turn's `turnId`, `livenessSeconds: 30` and its rising `sequence` and `occurredAt`, and stops at the turn's `Stop` and at dsh's exit. It pauses from a permission ask until the agent has its answer, and posts `active` / `permission_resolved` with the turn and `askId` once no ask waits and none is being submitted, never after a refusal, a cancel, a permit with no ask open, or dsh's exit. The agent's own reports go out only when a beat could.
The conformance check's cases 4b and 7 now count only the non-exit `Stop`s posted after the turn's accepted prompt, ordered as OpenRig orders them (`occurredAt` when it reads as a time, else arrival; then a positive `sequence`), so a runner's idle report from before the turn is no longer a false failure. The check still doesn't validate those two fields' types, and it is stricter than OpenRig for a `Stop` that precedes a later post that arrived first.
Example and check +69/-16, tests +213/-16.
A restore whose snapshot has no saved startup context reaches #2338's existing hook recovery only for a legacy (non-pod) seat whose live runtime still matches the snapshot; a changed runtime or a pod-aware seat gets no new hooks write, and the restore itself is unchanged. Every restore step that uses the seat's directory now takes the launch directory (the snapshot's cwd, else the live node's, as the launcher does): the projected hooks, the startup replay's plan and binding, the legacy resume's managed launch and read policy, and both saved-guidance checks. A snapshot with no cwd therefore no longer writes the context collector or settings into the daemon's own folder, or prepares a resume at the filesystem root. Seats whose snapshot has a cwd are unchanged.
Restore orchestrator +48/-12, tests +232/-6.
The queue list route accepts a sort by last update, and the TUI uses it for JUST FINISHED on Pulse and RECENTLY FINISHED on an agent's page, so those lanes hold the items closed most recently instead of those created most recently. It is a read order only: no queue state, write or delivery path changes, and the default list order is unchanged.
Source +13/-10, tests +33/-8.
The transcript store's forward reader keeps an unfinished line as fragments until a newline arrives, instead of prepending it to each new 64 KiB chunk and splitting the whole result again. A very long captured line is therefore scanned once rather than once per chunk, so `rig transcript --grep` holds the daemon for less time. The lines it yields, and so the grep output, are unchanged.
Transcript store +11/-5, tests +14/-0.
`rig tui`'s Pulse shows each JUST FINISHED row's clock in the TUI's configured time zone (`ui.timezone`), through the existing display-time helper, rather than as UTC with no zone mark. Other Pulse lanes and the stored timestamps are unchanged.
TUI +12/-16, tests +23/-2.
The workspace frontmatter validator reports an opened but unclosed frontmatter block, and a YAML sequence instead of a metadata mapping, as a parse-error gap, including when no workspace kind is selected. An unclosed block is recognized as present rather than silently treated as body text. The report path, gap kinds and HTTP response are unchanged, so `rig workspace validate` now exits 1 for such a file through its existing gap exit status; nothing else gates on it.
Validator +4/-3, tests +50/-0.
`rig scope ... progress` edits only the tasks a reader sees. `--set` no longer changes or counts example checkboxes inside fenced code blocks, and a text found only inside an example takes the existing no-match error, leaving the example untouched. `--add` ignores example headings and rows when it picks a section, and inserts before an unfinished trailing fence rather than inside it. The fence rules match the progress indexer's.
Command +36/-7, tests +42/-0.
`rig down` removes OpenRig's managed blocks from the guidance file of every seat runtime that writes one, now including Pi and omp (AGENTS.md), with the same target used for teardown, live-file protection, exact resume and node inventory. Once a working folder has no live Claude seat of any rig, down also removes OpenRig's own Claude additions there: the collector status line and the relay hook groups in .claude/settings.local.json (matched exactly as launch claims them, keeping other hooks and fields), and .openrig/context-collector.cjs when its bytes still match the shipped asset. Empty .openrig and .claude folders are removed without recursion, and settings.local.json is kept as {}. Edited, unreadable and still-shared files, including targets another live seat reaches through a symlink or hardlink, are kept and reported. An exact resume puts its activity hooks back.
Source +211/-64, tests +248/-8, docs +4/-2.
The /broadcast route adds its unsigned-sender notice only when the broadcast reached at least one recipient, counting sends a recipient holds for later delivery (sent + retained > 0). When every recipient holds the text, the notice begins "Held for delivery" rather than "Delivered"; when it reached nobody, there is no notice. The CLI prints the route's warning as before.
Source +9/-4, tests +31/-0.
A harness runner may post `active` / `liveness` beats while a prompt is outstanding and the agent it started is alive, so a long tool or model call keeps the seat reading working. A beat counts only when it carries the seat's live generation, names the latest accepted turn that hasn't stopped, carries a sequence above every earlier counted post, and the seat has no attention outstanding; otherwise it is ignored, and it never starts or extends a turn or clears attention. After delivering an answer or permit, a runner posts `active` / `permission_resolved`, which clears attention. Section 4 of the runner contract documents both from 0.7.9; section 7 is unchanged. The conformance checker gains the beat cells. A runner that sends no beats behaves as before.
Source +181/-8, contract and conformance kit +100/-2, tests +313/-7.
`rig seat set-runtime`'s refusal for a seat with no bound tmux pane now says what to do: start a stopped seat first, then switch it; for a seat that never started, declare the other runtime for the member in its rig spec and bring the team up again from it, which makes a new generation without the seat's edges, settings or queued work. `rig seat set-runtime --help` says the same. The existing refusal passes one guidance line; nothing changes in how a seat switches or starts.
Source +11/-2 (text and help), tests +6.
`rig queue create`, `handoff` and `handoff-and-complete` lowercase a documented --priority (routine, urgent, critical) given in another case, so `--priority Critical` files as critical. Any other value is kept exactly as given, with one note on stderr that it is outside the documented choices. create still defaults to routine, and a handoff with no --priority still sends none, so it inherits. --tier is unchanged.
Command +14/-3, tests +56/-3.
`rig queue list --state` warns on stderr about any name that isn't a queue state, naming the supported states, then sends the query exactly as before. A mixed filter still returns its known-state matches; an all-unknown filter still returns nothing, and its warning says an empty result doesn't mean an empty queue. Stdout, --json, the empty and default filters and the HTTP API are unchanged.
Command +13/-0, tests +55/-0.
`rig export <rig>` with no -o no longer replaces an existing rig.yaml. It claims the next unused sibling (rig-export.yaml, then rig-export-2.yaml and so on) by creating it exclusively, writes the export there, and prints the path only after the write succeeds. An explicit -o keeps its replacement behaviour, including -o rig.yaml, and filesystem or export failures still exit nonzero.
Command +39/-5, tests +79/-1.
0.7.7 is published, so main's CHANGELOG records it: the [0.7.7] section reads published 2026-11-02 as @openrig/[email protected], built from the commit tagged v0.7.7, and the 0.7.7 status paragraph inside [0.7.8] no longer says it isn't published. [0.7.8] itself stays not yet published. docs/reference/whats-new.md, versions and pins stay as main has them.
CHANGELOG.md +2/-1.
When a person answers a runner's permission ask in words that are neither a choice nor a plain yes or no, the read result (`GET /api/activity/permission-requests/<askId>`) now carries those words, whole, as `said`, beside `permitted` (closed without a choice) or `refused` / `no_allow_offered`. The words come from the row that holds the answer, so a colleague's words count when the ask was routed to them. A plain yes or no keeps today's mapping with no `said`, and words are never read as a no.
The harness runner contract's section 7 gains two passages describing `said` and how a runner delivers it: queued for the agent as an ordinary prompt, after the current turn and behind any prompt already waiting, never as the answer. A runner that drops them still keeps the contract. The dsh example runner and the minimal ACP conformance fixture deliver the outcome as before and queue the words; the conformance check covers it.
10 files: runtime +20/-4; the dsh example runner and the conformance fixture +18/-2; the contract, the dsh README, the conformance README and check +34/-6; tests +61/-7.
The OpenShell guest reader opened `/` before walking to the selected workspace, which a sandbox that grants only the workspace refuses, so a worker never started. It now anchors descriptor traversal at the selected root, with no ancestor opens, and keeps the selected-path symlink checks and `O_NOFOLLOW` reads beneath it.
When a resource is created but projection fails before a generation exists, `rig environment` can now delete that owned resource by its selected, immutable resource id, with no generation or node binding required. Attached resources are refused before any mutation, the one-dispatch journal and dependent cleanup are kept, and the selection is checked again after the driver's final identity call. Uncertain or lost replies still require observed absence.
The example `rig.yaml` gains its required pod label and a pod-local delegation edge, and validates through the normal parser and plan path. The CLI help and the OpenShell README describe cleanup by resource id, `OPENRIG_SESSION_NAME` as attribution rather than authentication, and guest-reachable listener addressing.
Runtime +30/-14, tests +165/-2 across five files. No new error code, dependency, migration or policy.
Carries the 0.7.8 section of `CHANGELOG.md` from `release/0.7.8-prep` (16121548) onto main byte for byte, adding only a "Not yet published" line, plus the 0.7.7 status paragraph from its release source. Neither version is dated or marked shipped; each label changes when that version is published. `docs/reference/whats-new.md`, versions and pins stay as main has them.
CHANGELOG.md +167/-0.
`rig specs ls`, `show` and `preview` accept any --kind. For a value other than rig, agent or workflow, the command prints one stderr warning naming the supported kinds, still queries the daemon with the value URL-encoded, and keeps only the entries whose kind matches exactly. A known kind trusts the daemon's filtered list as before, so `rig up`'s library lookup is unchanged. When a --kind filter matches nothing, `specs ls` prints "No <kind> specs found" instead of the empty-library message. The warning goes only to stderr, so stdout and --json stay parseable.
Command +35/-10, tests +105/-1.
Launching seats that share a working directory made its `CLAUDE.md` and `AGENTS.md` grow by one byte per managed block per launch. When `mergeManagedBlock` replaced a block already in the file, it wrote the result plus a newline even when the result already ended in the previous merge's newline. Now it adds the newline only when it's missing, so a file is the same size from the second merge on. Every byte outside OpenRig's blocks is kept, including blank lines a person left after the last block, so a file that already grew isn't trimmed. A first write, an append and the replaced-id trim are unchanged; a CRLF file is left alone on re-merge, and `rig down` still gives back a file's exact bytes after one start or two.
Source +4/-2 (one expression plus comments), tests +46 new and two reworded comments. Reported in #2372.
A Claude seat's rebuild handover could stop at "Waiting to confirm the new Claude process in this pane" although the new Claude was running, when its argv[0] was spelled through the native installer's symlink (`~/.local/bin/claude`) rather than the versioned file it points to. A managed launch freezes the realpath, and readiness compared a path-shaped argv[0] only with that frozen path; the OS executable path was read only for other row shapes.
Now, when the exact-path match finds nothing for a launch that froze an executable, readiness admits a foreground process whose argv[0] is an absolute path named `claude` and whose OS executable path (on Linux, `/proc/<pid>/exe`) is exactly the frozen file, absolute with no `.` or `..` segments. The process must still name this launch's own conversation, be the only candidate, look the same in two samples under the same pane, and its first match is pinned. Only such a process has its OS path read: an exact launch, run with exactly the frozen path, is proved without it, as before, so a slow path read can't change its pinned fingerprint. Another install's binary, an unreadable or relative OS path, another conversation and a process outside the foreground group are still refused. The `#!` wrapper rule from #2354 is unchanged.
Runtime +25/-8, tests +64/-1. Refs #2341.
When the daemon answers `rig workspace validate` with an error status (400 or above), the command now prints the daemon's message as one line on stderr, falling back to its error code and then to "Validate failed (HTTP <status>)", instead of the raw JSON response. Every error this route returns carries an error code and a message, such as `invalid_workspace_kind` for an unknown `--kind`, so the line is that refusal's own text. Under `--json` the command prints the same JSON as before, and the exit code is still 1. The daemon remains the only place that lists the workspace kinds; the CLI adds no check of its own.
Command +11/-2, tests +81.
The runner conformance check (`docs/reference/runner-conformance/check-runner.mjs`), which runner authors run on their own machines, could falsely fail a correct runner on a busy machine at case 4b's read cells. After a permission read that OpenRig answers with an unlisted status (such as 500) or `activity_grant_stale`, it waited a fixed `scale(4000)` for the agent's outcome, tighter than section 7, which sets no time limit after a read. That wait is now the check's own per-step observation budget, `opts.waitMs` (8 seconds by default, scaled by `--wait-ms`). The unreachable-read cell keeps `scale(10000)`, since a runner may retry a read that got no answer. A dropped or wrong outcome still fails the same exact checks. The test double gains a `--read-retries <n>` mode, and a test cell runs case 4b with it.
Check +3/-2, test double and test +17/-1.
A Pi seat mid-turn now keeps reading `working` through a long, quiet model request (local inference, a slow provider) instead of flipping to idle or unknown after 15 seconds. The Pi runner already had an idle heartbeat; it now also posts an `active` beat while its `streaming` flag says a turn is in flight. The heartbeat timer ticks every 5 seconds and a beat goes out once 5 seconds have passed since the runner's last post, real or beat, so a working hook stays inside the seat-activity oracle's 15-second `HOOK_AUTHORITY_WINDOW_MS` on ordinary on-time ticks. The beat stops when `streaming` clears at the turn's end, an abort or error, and with the runner's exit; frequent real events postpone it, and the idle heartbeat's three-minute gate is unchanged. OMP keeps its no-op timer. `main()` starts the same exported timer the new fake-timer tests drive.
Runner +55/-13, tests +167/-4.
Runner contract section 8 now names ACP's `session/resume` (capability `sessionCapabilities.resume`) beside `session/load`, which is what dsh `0.2.1-rc.2` advertises, and marks open question 1 answered. It also says how a runner tells a missing conversation from one that won't reopen, since an agent may answer one error for both: missing comes only from the agent's own not-found (`-32002`), anything else is unconfirmed, and an agent's `session/list` is positive evidence only. An unlisted conversation isn't missing unless the agent guarantees its list is complete, which dsh doesn't.
The dsh example runner reads the list first when dsh advertises it, following any string cursor unchanged (the empty string included) for up to 50 pages; a non-string cursor, an entry without a string `sessionId`, or a failed list decides nothing. It always tries the reopen: a reopen that works resumes, `-32002` is missing unless the list named the conversation, and any other error, `-32602` included, is unconfirmed. It never starts a new conversation. The dsh README and `dsh.yaml` limits say the same, and the runner keeps its executable mode.
Runner +24/-2, contract and dsh docs +30/-7, tests +82/-1.
A clean install of the CLI couldn't start: bare `rig` and `rig --help` exited 1 with `ERR_MODULE_NOT_FOUND` for `@modelcontextprotocol/sdk`. `packages/cli/src/commands/environment.ts` imports the SDK client at module load and `index.ts` loads the environment command statically, but the SDK was declared only in the CLI's `devDependencies`, so an install without development dependencies didn't have it. The SDK (`^1.32.2`) is now a production dependency of `packages/cli`; the lockfile update is metadata only (its dev flags), with no package added, removed or re-versioned. `scripts/cli-runtime-dependencies.test.mjs` walks the import closure from the CLI's entry points and fails if any external package it reaches isn't a production dependency, and the testbed image now runs `rig --help` after `rig --version`.
4 files, +77/-87.
The capability map said a typed reply to a question parked on its own person resumes it but isn't recorded as the answer. Since #2293 it is: that person's typed reply resumes the question and is kept the same way as on an unparked ask, in the same transaction. A reply whose order against the posted question can't be confirmed resumes it with the words in context and isn't recorded. Only that sentence changes.
Text only. 1 file, +3/-2.
The runner conformance check (`docs/reference/runner-conformance/check-runner.mjs`), which runner authors run on their own machines, could fail a correct runner when the machine was busy. Case 1 waited 800 ms after seeing `session/new` and took one sample, so if the check's own timers ran late it called a correctly written launch record early; it now samples the record against the agent's log for the whole unanswered window, which is at least as strict. Case 4b required the cancelled or ended turn's `Stop` to arrive after the withdraw, an order section 7 doesn't promise; the turn-end checks now count only the `Stop` that follows the agent's own `session/prompt` reply with its stop reason, on either side of the withdraw, so an early idle `Stop` can't stand in for a missing end. Check names, the contract text and the kit's README are unchanged.
Check +28/-6, tests and fixtures +46/-4.
Main's CHANGELOG gains the `## [0.7.7]` section, byte for byte as on `release/0.7.7-prep`, directly above `## [0.7.6]`: 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats (the "Update installed" footer reads idle, managed compaction doesn't type into an unsent draft, and a Claude seat behind a non-exec wrapper rebuilds, with a failed handover keeping its resume target), with its upgrade step, known limits and thanks. Nothing else changes.
Text only. 1 file, +60/-0.
A provider switch on a Claude seat (`rig provider switch --seat <seat>`) no longer refuses with `effort_would_change` when the seat runs with an `--effort` that differs from its saved, non-explicit effort. The relaunch keeps the running effort, so a person recovering from a limit or an outage doesn't have to set effort and switch again. An explicit `rig seat set-effort` choice still wins: restore's launch-effort seam reads the seat at launch, uses the choice when one exists (latest wins) and the kept effort only when none does, and reports what it selected on every keep-pane relaunch. The kept effort is never written to the seat. The switch's `effort` line is worded from that report; without one it says so, and a relaunch that doesn't report resuming says what was asked and the rule the recovery command follows, not a value read before the switch. An unreadable running effort proceeds as before and is disclosed. #2344's token-reference restore, pre-stop and unknown-effect handling are unchanged.
Follows #2344. Source +102/-25, tests +196/-27.
Runner docs that still described the first, refuse-every-request version of permissions now describe the answered path. The harness runner contract's four "0.7.7" labels for the answered path read "0.7.8", the release that ships it; section 7's text is otherwise unchanged. `author-contracts.md` gives the answered recipe instead of "refusal-first", `community-harness-presets.md` notes the runner-preset exception to "relays none", the conformance README no longer says a runner refuses another person's click or typed words, and the OpenShell guest README lists the three permission routes beside the hooks route. This is the same change as the docs commit on the 0.7.8 release preparation branch, so main stops describing the old behavior.
Text only. 5 files, +9/-9.
A managed Claude seat whose `claude` is a `#!` script that starts the real Claude without `exec` can be rebuilt again. `rig seat handover --source rebuild` on such a seat stopped after 30 seconds at "Waiting to confirm the new Claude process in this pane", though the new Claude was running: the wrapper keeps running the script as the pane's foreground process, with Claude as its child, and no process passed the script rule. Readiness now also admits the direct child of the process running the frozen script, in the pane's foreground group, when its arguments name this launch's own conversation token; the token match, single candidate, two stable samples and pane PID checks are unchanged, and every other process is refused as before.
A failed handover no longer moves the seat's resume target to the new, unprimed conversation while the seat keeps its previous occupant. A Claude `session_identity` hook carrying an occupant generation that isn't current, isn't registered for the seat, and arrives while the seat has a current tenure now answers `tokenPersisted: false` with `generation_uncommitted`; a successful handover still writes the launch token at commit. Codex is unchanged. Limit: a seat whose current generation was never registered (an adopted Claude carrying another generation, or a managed launch whose tenure record failed to write) keeps its first token, and later hooks don't move it.
Refs #2341. Source +33/-1, tests +138/-1.
`docs/reference/community-harness-examples/dsh/` adds a preset (`dsh.yaml`), an example runner (`openrig-dsh-runner.mjs`) and a README showing a runner author the runner contract end to end for dsh, a harness that speaks ACP: launch, activity, input, cancel and permissions. Permission requests follow section 7 of the runner contract: the runner submits the agent's live request, delivers the person's answer once, permits with the agent's allow option when nobody can be asked, and refuses and holds attention only on the seat's `--deny` or when no allow option is offered. `docs/reference/community-harness-presets.md` points to the example. A real dsh hasn't run it yet. One limit is named: the runner keeps one hold per seat, so a second request in the same turn can release an earlier one's hold. Docs and example +658, tests +447.
The runner conformance check now follows section 7 of `docs/reference/harness-runner-contract.md` as merged. Case 4: with nobody to ask, a runner permits with the agent's allow option (`allow_once` when offered), whether OpenRig lacks the answered path or can't be reached, and refuses at once only on `denied_by_seat` or when the request has no allow option. Case 4b checks the answered path itself: the live request is submitted with the agent's request id unchanged, reads wait at most 25 seconds (or not at all), exactly one answer reaches the agent, a stale or withdrawn request is cancelled with nothing held, and refusals are read by their code. A new test runs the check's test double against OpenRig's real permission routes with no model or provider. Docs and kit +324/-45, tests +377/-11.
Migration 145's comment said a null `nodes.permissions_to` meant the runner refuses and holds attention, which was the first version's behaviour. It now says what the merged code does: with no chosen person, the default person is asked, and the request is permitted when nobody can be asked. Comment only; the migration's name, SQL and behaviour are unchanged. +1/-1.
A new runbook, `docs/reference/pi-tool-selection-recovery.md`, explains how to recover a Pi seat whose tool selection went wrong, and `docs/reference/agent-startup-guide.md` links to it. For an agent OpenRig drives over RPC, it sends each extension command with `rig send <agent> --raw "<command>"`, so the command arrives without the messaging envelope while the usual interactive-prompt guard still applies. It also says what loading the linked helper registers: the agent-callable `openrig_runtime_status` and `openrig_slots` tools, a snapshot of the bound rig's agents and active queue items, and an optional prompt section when a guidance file is set. Docs only: +87.
An operator's `OPENRIG_WAKE_SUSPEND` override for a destination that has a colon of its own, such as `slack:U123@external`, now suspends that destination's wake retries for the declared window. Before, each entry was split at its first colon, so such a destination never matched and retries fired inside the window. Each entry now matches the checked destination's own prefix (`<destination>:`) and passes the rest to `Date.parse`, as before, so every date form main already accepted still suspends; other destinations and unparseable entries behave as before. This finishes #2114 by @Vaishnavi220506, who found the colon bug: their commit and its test are kept, with four new cells for the space and textual date forms. Source +7/-9, tests +25.
Co-authored-by: Vaishnavi220506 <[email protected]>
A runner seat's agent asks permission before an edit or a command; the runner contract's first version refused every request, so a job that needed one approved couldn't finish. Now a person answers and the default is permit. By default the default person (from `rig gateway default`, or the only registered person) gets an ordinary ask with the agent's own options; `rig seat set-permissions-to <seat> --to <person> --reason` chooses someone else and `--clear` goes back to the default, stored in a new nullable `nodes.permissions_to` column (migration 145), audited and shown in `rig seat status`. When no one can be asked, the request is permitted at once with the agent's allow option, preferring `allow_once`. `--deny` opts a seat in to refusing and holding attention. A click on an offered option by the asked person, another registered person or the primary user gives that option; a plain yes gives allow; a plain no or a Feed denial gives reject, or cancelled without one, and is never a grant; a handoff to another person keeps the options; no answer stays pending until the turn ends. The runner submits, reads and withdraws through `/api/activity/permission-requests`, and an answer reaches only its live request, once. Section 7 of `docs/reference/harness-runner-contract.md` carries the full rules. Source +792/-45, docs +128/-10, tests +661/-1.
After a human decision was resolved, its Slack notice re-rendered the original question with live buttons and the typed-reply hint, so it read as a new ask. `buildOutboundMessage` now renders a `human-decision-resolved` notice's questions and options as plain text, with no actions block and no reply hint, in both the blocks and the fallback text. A decision closed by a button click writes the same notice kind, so it gets the same rendering. Open and shared-channel asks are unchanged. Source +6/-3, tests +18.
The CLI's `host-bindings` and `restore` tests built filesystem paths from `URL.pathname`, which stays percent-encoded (and is `/C:/...` on Windows), so both failed from a checkout whose path contains a space or a non-ASCII character. They now use `fileURLToPath`, as the other CLI tests already do; the contention child still runs in `packages/cli` and the restore test still reads `src/commands/restore.ts`. No other `import.meta.url` pathname used as a file path remains in `packages/` or `scripts/`. Not run on Windows. Test-only: tests +4/-2.
Managed compaction could append its preparation prompt to an unsent draft when the optional typing guard was off, before its idle wait and including `--skip-map` requests. The preparation prompt, `/compact` and owed restore input now opt into the adapter's existing pre-paste composer check, through an internal send option that only the compaction enforcer sets. Typed, unreadable or changed input stops before any paste with the existing `draft_*` reason, the attempt or owed stage stays inspectable and retries once the box is empty, and the manual route explains the hold. Empty input and high-confidence suggestions go ahead. Ordinary sends, other notices, typing defaults and the existing busy, occupant and permission checks are unchanged. The terminal observation is not atomic against simultaneous manual typing. Merged with main's bounded idle wait for completed maps, whose `/compact` send keeps the same check. Source +22/-4, tests +109/-26.
A contained Claude resume whose saved startup context has no `claude_activity_hooks` entry no longer comes back silently without its activity hooks. When the seat's current specification still selects the hooks, restore re-applies the current marker through the same adapter seam as the saved-entry path and warns. A healed re-apply stays a warning and doesn't downgrade the rig; a re-apply that was skipped, failed or threw, including one where no Claude runtime adapter was available, records a restore gap, so the rig reports `partially_restored`. When the current specification doesn't select the hooks, nothing changes; when the selection can't be determined, restore warns and records no gap. `rig restore-check` reads the same current selection. Refs #1530. Source +175/-26, tests +225/-3.
After `rig provider switch --seat <claude-seat>`, the seat's recorded token reference matches the process actually running, and the seat keeps the effort it was running with unless its own saved choice says otherwise.
- A kept-pane launch that failed before its takeover sent any signal is now marked `nothingStopped`, and the switch reads it as no effect and puts the old token reference back. Failures after TERM, and a process that didn't end after TERM and KILL, still read `unknown` with the recovery command; the message for an untouched seat says it is as it was before the switch.
- Before touching the seat, the switch reads the running Claude's `--effort` from its argv. When the relaunch would change it and the relaunch's effort isn't the seat's explicit `rig seat set-effort` choice, the switch stops, changes nothing, and shows both values and the `rig seat set-effort` command that keeps the running one. It goes ahead for an explicit choice, a process with no `--effort` flag, or a process it can't read.
- The pane-process walk behind model reads and expected-conversation checks counts the pane process itself when its argv0 isn't a shell, and recognizes a native install's Claude by its versioned path.
A relaunch still uses the seat's saved model. Source +57/-10, tests +80/-2.
`rig daemon status` no longer prints "Daemon stopped" while a daemon start holds `daemon-start.lock` and its listener isn't up yet. When the probe is refused and the reservation names a live launcher or child PID, status stays `unverified` with a `startPending` record and prints "Daemon starting: launch reserved by pid N since T; not listening yet". Commands that need a responsive daemon treat a pending start as nothing listening and stop with "Daemon is starting ... Wait, then re-check with 'rig daemon status'." The refused probe's code is kept. No new status value; start and stop sequencing, the reservation's lifecycle, the readiness deadline and the `OPENRIG_URL` branch are unchanged. Source +70/-7, tests +147.
A valid host alias whose name matches an Object prototype property, such as constructor, toString or valueOf, now learns its identity on first contact. Before, recordUnderLock read the inherited property as a prior binding, reported a false conflict and wrote a sidecar entry without a host id. It now checks for an own property, and real bindings match and conflict as before; the other readers of the bindings read only a host id or conflict flag and were already safe. Source +1/-1, tests +23.
When someone watches a tmux server through a read-only client (tmux attach -r), tmux 3.7 and 3.8 refuse every send-keys, including -l, so each agent's submit key stalled with its text typed but not sent. A lone Enter or C-m that tmux refuses with "client is read-only" now goes as a carriage return through the existing guarded paste route (load-buffer, then paste-buffer -d -r without -p), which tmux doesn't block for read-only clients. Just before the paste, the same checks as before the key run again: the delivery guard, the caller's original-input check, the pane lease and the caller's input check; a change in that gap stops the carriage return, and a successful submit is recorded once. Other keys, key sequences and other refusals are unchanged. Not run natively on tmux 3.7 or 3.8. Finishes #2090. Source +24/-4, tests +88.
Co-authored-by: Mydudeclaps <[email protected]>
Following #1037's decision, a typed Slack reply still closes a structured decision, and the agent now has what it needs to read it correctly. The question's block and fallback text say a typed reply submits the answer without waiting for every question; shared-channel questions keep their existing hint. The correlated inbound answer's attribution footer, not the answer text, tells the agent to read the person's words rather than treat closure as approval, so the words recorded in humanAnswers are unchanged, and a replayed event carries the same footer so deduplication stays stable. This is guidance, not enforcement: decision close, buttons, delivery and shared-channel behaviour are unchanged. Source +16/-5, tests +18/-1.
rig export now preserves member-level startup declarations: startup files and actions come from the seat's retained launch recipe, or from a unique pre-launch materialization, and a new rig whose startup files are member-only keeps its source root. When the original source is unavailable, or its files can't be represented under the export's single root, the export names what it omitted, and the CLI checks required member files beside its output. The rig spec reference documents the provenance, missing-file and legacy limits. Pod startup actions are outside this change. Source +55/-6, docs +4/-3, tests +209.
rig down no longer deletes a seat's recorded skill copy when the source it was taken from has changed or disappeared. rig up --existing then restores the copy the seat had, its saved selection, instead of reporting it missing with nothing to put back. When the source is unchanged, down still removes the copy and restore rebuilds it from the source, as before, and deleting the rig still removes every recorded copy, since nothing would restore it. Source +15/-5, tests +48/-2.
When removing an agent image's .pinned sentinel fails, for example because the path is a directory or deletion is denied, unpinning now reports the error and keeps the image protected in memory. Before, it reported success and made the image eligible for deletion or pruning while the sentinel was still on disk. An already absent sentinel still unpins successfully. Source +4/-2, tests +23.
A Codex in-place restart that refuses with pane_shape_unknown gave a generic refusal with no evidence of what it saw. Its JSON result now includes detail.panePid and detail.processChain, ordered from the selected Codex process toward the pane, with only pid, parent pid and observed process name; a missing name stays null. The human-readable guidance points to rig seat restart --plan --json and asks for both the CLI and daemon versions. This is diagnostic support for #2200; it doesn't change which panes are accepted. Source +6/-1, tests +33/-1.
When OPENRIG_TMUX_CONTROL=1 is selected, OpenRig now starts its control client only if the tmux server's version identifies a release with the simultaneous-start fix (3.7c or newer). Older, development and unrecognized servers fall back to the existing spawned-client path, so OpenRig no longer creates its own vulnerable control client there; the opt-in speed-up is unavailable on those servers. This doesn't fix tmux or protect against control clients started by other tools, and existing command and unknown-write behaviour is unchanged. The tmux server guide names the version requirement. Source +9/-6, docs +5/-1, tests +9/-11.
The rig tui instance table drew a dotted rule between pods inside a rig but nothing between rigs, so one rig's last pod shared a block with the next rig's first pod, and trailing single-pod rigs ran together. Each rig boundary now gets a solid rule, and pods inside a rig keep the dotted rule. Source +4, tests +2.
A queue row's body written in Markdown now renders in Slack instead of showing raw **bold**, ## headings, [text](url) links and hyphen lists. Bold, heading lines, links (including bare URLs in list items), code spans and fences render as Slack mrkdwn, and lists become native Slack rich_text list blocks, with nesting and ordered numbering, falling back to text at the block limit. A hand-written Slack link <url|text> is kept as a link. Mentions and Slack control syntax stay literal text, unsafe links aren't linked, and secret redaction runs as before. A fenced code block split across several messages is closed and reopened in each part, with room reserved for the delimiters. Source +210/-8, tests +252.
A seat stuck at a permission or choice prompt keeps getting its escalation to someone when human delivery is unavailable. Generated escalation rows now stay on an admissible local agent carrier with OpenRig attribution and keep their ids, history and member obligations. An unreadable or missing person registry no longer rejects a valid agent carrier. An unaccepted legacy person carrier is rerouted through the existing fallback on the same row, and a newly configured lead gets one wake before the human rung; choice prompts stay explicit-only. Human-delivery readiness is checked before a new dispatch, and findings about undelivered escalation notices stay visible without escalating recursively. Ordinary rows without an owner-notification key now take their notification and decision identity from the existing meaningful-progress episode, so an escalation that returns after progress posts again even when the earlier notice was delivered and acknowledged; retries within an episode keep their identity, and accepted gateway deliveries keep custody. Source +118/-18, docs +8/-2, tests +224/-11.
A seat that has never started now starts on its first rig launch <rig> --seats <seat>. Before, a never-started pod seat (no startup context, nothing to resume) failed with "Fresh launch failed: Input target/occupant changed; no input written", and no agent process started. Restore retires the provisional shell before handing the seat to the persisted fresh launcher, but it left the lifecycle lease naming the retired binding, so the launcher's ownership check refused. Now retireProvisionalSession checks the lease still holds the seat after the stop, then clears the binding and rebinds the lease with no await between, as clearStaleState already does. A concurrent rebind during the stop refuses and is kept, and a replacement after the rebind still refuses at the launcher. --fresh, --retry-startup-from and the pristine single-seat first start are unchanged. Source +8, tests +66.
A Claude seat that is busy when its compaction preparation deadline passes no longer loses the restore map it already wrote. Before, the attempt ended with preparation_incomplete even though the map was complete, the next attempt asked the seat to write another map, and context kept climbing. Now a completed automatic map stays armed and /compact is sent at the seat's next idle moment, bounded by the original preparation deadline plus the existing retry interval (35 minutes by default), the same bound after a daemon restart. While it waits, the seat isn't asked to prepare again. The context monitor finishes an armed map when usage readings have gone stale or unknown, but a fresh reading below the threshold still holds compaction, and nothing new starts from stale usage. An attempt that expires with a finished map reports map_complete_seat_never_idle, distinct from preparation_incomplete, in the routes, rig compact --state and the help. The manual 180-second budget is unchanged. Source +56/-17, tests +79/-14.
With OPENRIG_TMUX_CONTROL=1, the tmux server no longer crashes, taking every session with it, when a daemon's control client ends while another client leaves, on tmux 3.3a to 3.7c. Before tmux 3.8, a client whose input has closed stops counting as attached while the server still holds it, so another client leaving in that window had destroy-unattached free the control session under it. The control session now sets destroy-unattached off, whatever a tmux.conf sets globally, and a session client-detached hook, if-shell -F '#{==:#{session_attached},0}' kill-session, so the session ends only once its own client has gone and kill-session takes any remaining client out first. The session_attached guard stops the hook killing a control session when tmux fires it for another session's lost client. The crash while two control clients start at the same instant, before tmux 3.7c, is left open. Source +18/-8, tests +43/-4 (unit and native tmux).
The host registry could only gain entries, so a mistyped target or a retired machine could only be fixed by hand-editing hosts.yaml. This adds rig host edit <id> (--target, --user, --url, --notes; an empty string clears an optional field) and rig host remove <id>, with matching POST /api/hosts/update and /api/hosts/remove routes beside /add. An edit keeps the host's id and transport fixed, and unknown fields fail with the editable set for that transport. Both routes refuse secret-shaped fields with add's no_secret_values check before any lookup or write, never overwrite an invalid registry, and go through the same parity-pinned writer and validator as add, with byte-parity tests across the CLI and daemon writers. Remove deletes only the registry entry: no credential files, no learned identity, nothing on the remote machine. Both report wasSelected and leave the selection pointer unchanged, and the CLI says where the selection stands. The routes are bearer-gated exactly like /add.
A charge post retry that comes due while another OpenRouter lookup is in progress is no longer dropped when that lookup returns a retryable response. Before, flushAlone deferred to the running pump, but the pump flushed only after a lookup reached an outcome, so the post waited for a later outcome. A flag now records the due retry, and the pump serves it once after a retryable outcome. A successful lookup's normal flush posts everything unposted and clears the flag, so nothing posts twice. A parameterized regression holds one lookup with another queued, fires the retry mid-lookup, and checks the 404 and success cases. Source +9/-3, tests +27.
The roadmap names the current release. The "Released in 0.7.5" section becomes "Released in 0.7.6": its one line links the published 0.7.6 release and summarises it in that release's own title, and points to its release notes for what shipped, known issues, upgrade instructions and contributor credits. Nothing else in the file changes. One file, +2/-2, text only.
An application that selected a Claude Code or Codex conversation could read or send through its seat name after that name moved to another native conversation, and get the other conversation's output or deliver into it. Two opt-in endpoints now check an expected identity (node, canonical session, runtime and native ID) where Core reads or injects: GET /api/sessions/:sessionName/generation-record-expected returns the checked tuple with bounded bytes from the same opened record, stopping at the first identity-bearing line, and POST /api/transport/send-expected rechecks after buffer preparation before paste and again before Enter. The expectation stays on the durable outbox receipt through held input, committed dispatch and delivery-id reuse. A mismatch or unavailable identity returns 409 with only documented codes, and during a preparing or confirmed runtime switch a conditional send returns 409 expected_identity_unavailable with nothing retained. Older daemons answer 404 rather than ignoring an unknown field. Callers without an expectation are unchanged. A native change after the final observation before Enter remains a race, terminal attachments are unchanged, and a delivery receipt doesn't prove model consumption. Source +275/-31, tests +243, docs +51; no new dependency or migration.
A Claude seat at an empty prompt reads idle after Claude installs an update, instead of unknown, so managed compaction can see it is idle and send. Claude prints "✔ Update installed · /restart to apply" (or "Restart to apply") below its mode bar, and CLAUDE_STATUS_WARNINGS didn't list that line, so the footer finder never reached the mode bar. One anchored entry now matches both spellings exactly. The #808 readiness cases run over both spellings across every mode bar, and three near misses (a suffix, no glyph, another verb) still read unknown. Source +2, tests +18/-1, no new refusal or error code.
OpenRig's managed seats can now run on a tmux server of their own instead of sharing the person's default server. `rig config set terminal.tmux_server isolated` keeps them on a persistent server derived from the canonical OpenRig home, so they stop cluttering the person's own `tmux ls`. `inherit` remains the default and keeps the existing native server selection and attachment behaviour; both modes keep normal tmux configuration. The selection is made before reconciliation, and a change waits while this instance's registered seats still run on the previous server, or while that server can't be inspected; it never moves or kills a live pane. Launch, capture, inventory, terminal views and cleanup use the effective selection. Ordinary inheritance tolerates missing or invalid advisory records and gains no new identity prerequisite; strict record and identity checks apply to isolation. Fixed isolated and migration selections carry a durable pin, and an opt-out records it before removing the marker, so startup and offline cleanup agree after an interrupted update. SSH tiles follow the viewing instance's effective mode: inherited views keep `ssh -t … tmux attach`, and isolated views use the remote `rig terminal attach` and never fall back to a same-named session on the default server. The reference includes a two-instance recipe with separate homes, databases and ports.
openrig.dev serves these contract pages from the release tag, and they still described 0.7.6 as an unreleased candidate. The harness runner, execution platform and joined author contracts now say the drafts were published with OpenRig 0.7.6 (tag `v0.7.6`). The `runner: true` with `launch_result: true` and `passes_model: true` rows read "Released in 0.7.6", in the published release notes' own terms. The implemented-interface baseline stays `b6bd4633`, and the published release adds repairs and documentation on top of it. The contracts stay drafts. Nothing about later releases is added, and the permissions section is unchanged. Text only: three files, +11/-11.
A seat placed in an OpenShell sandbox can now be stopped at the level its owner chooses, and brought back exactly. `rig environment effect` closes the viewer, stops the native agent, stops compute, or deletes an owned environment, and each returns what was observed: pending, confirmed, refused or unconfirmed. An attached environment is never stopped or deleted, only its viewer is closed. Deleting an owned one is confirmed only when its workload, supervisor and volumes are observed gone, and a failed lookup isn't treated as absence. Each effect has one operation identity, so a daemon restart or lost reply reconciles it without a second provider call. A finite run can be selected with `rig environment completion`: once its own task closes done with a verified export, the chosen stop runs, after eligibility is checked again just before dispatch; a pending stop is re-checked at most once a minute. `rig environment checkpoint` keeps the native history privately, and `rig environment recreate` replaces owned compute on the same host. A normal restore then resumes the exact saved conversation and first artifact, delivers the current resolved context and verifies it at the destination, and refuses missing or changed history instead of starting fresh. The recorded requester is transport-attributed. Stop and delete reach the vendor by name after the exact id is checked, so a deliberate reuse of the name in that gap is a stated limit; others are in the README.
The packaged-plugin version check (`scripts/check-packaged-plugin-versions.mjs`) requires a bundled plugin's version to advance when its files change, measured against `scripts/fixtures/packaged-plugins-last-release.json`. That baseline still described 0.7.2 (openrig-core 0.1.19), so a plugin change since 0.7.6 could pass without a version bump, and two installs could carry different hook or skill files under the same plugin version. The fixture now describes the published 0.7.6 package (openrig-core 0.1.23), checked byte-equal against the published tarball and the v0.7.6 tag's tree. openrig-core moves to 0.1.24 in both harness manifests: main was still at 0.1.21, because 0.7.6's version bump lived only on its release branch, and the plugin's files have changed since. Against the old baseline the check passed wrongly; against the new one it fails at 0.1.21 and passes at 0.1.24. Three files, +6/-6; no source or test change.
A runner author can now check their runner against the harness and runner contract with one command: `node docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`. It needs Node and tmux, and no daemon, model, credentials or network. It starts the runner in a private tmux server with a runner preset's environment and an empty OpenRig home, delivers input the way `rig send` does, and records what the runner posts to a stand-in for the activity endpoint, against a scriptable ACP agent. The cases follow the contract: the launch record and a failed handshake, framing, queued messages and cancel, activity and generation, refused permissions, resume, death mid-turn, errors, unknown requests and first input. Shutdown, which section 9 marks as proposed, is reported as advisory, met or not met, outside pass, fail and the exit status. `--wait-ms` scales the check's waits and the fake agent's own delays together, for slow machines and slow-starting agents. The README says what each case checks and what it leaves out. The contract's section 10 points to the check, and it ships in the package under `daemon/docs/reference/runner-conformance/`. It checks the runner's side only; a run with a real agent is still the useful evidence, and the answered permission cases follow separately.
On a machine shared by two OS users, a second user's fresh install could reach the first user's OpenRig daemon on the default port, while `rig doctor` passed and `rig setup` reported ready. Doctor's port check now reads the pid and euid that `/healthz` reports and compares the euid with the caller's. Your own daemon passes. Another user's daemon is a warning, with the fix: `rig config set daemon.port <free-port>` then `rig daemon start` for your own, or `OPENRIG_URL` to use a shared daemon on purpose. It's a warning rather than a failure because a daemon shared through config alone looks the same and has to keep working. An owner that can't be compared (an older daemon, a platform without an euid, or a body that doesn't finish within 2 s) warns that the owner is unknown, and that read is bounded and released. An endpoint chosen with `OPENRIG_URL` still passes and names whose daemon it is, with `localhost` matching a configured `127.0.0.1`. Setup's "Some steps need attention" list now names failed checks and this warning with their fixes, and setup stays ready on the warning. The owner is the daemon's own report, not authentication, and every line says so. Nothing is stopped or refused.
A team can now place a seat's worker in an OpenShell sandbox through the maintained `openshell-claude` community runtime. `rig environment` explicitly creates an owned sandbox or attaches an exact existing one, and the binding records the resource, ownership and generation, so a lost create response is reconciled without a second create. Before the native agent starts, its current guidance, skills and startup files are delivered and verified in the guest; a missing required file refuses the launch instead of starting cold. Artifact return reads one bounded regular file, checks type, size, hash and context, and publishes the host bytes with a receipt on the existing task, without closing it. The guest uses a separate MCP work listener and an opt-in activity listener that serves only the grant-checked hook post from #2295, so it never reaches the daemon's main port; it receives a seat- and generation-scoped grant, never the install-wide hook token. A refused attach leaves the seat free for a corrected one, and a saved execution is refused before any projection or binding write. `rig environment setup` installs the preset, policy and image recipe and example specs, and its help points away from `rig env`, which shows a rig's services. Ordinary host startup is unchanged. Activity for the bound guest reads unknown in this version, and lifecycle stop, delete, checkpoint and exact restore come separately.
A runner or guest outside the host's own hook setup can now report activity with a credential scoped to one seat and one launch generation, instead of the install-wide hook token that speaks for every seat. The hook ingress resolves who is posting before it accepts the seat and generation a post claims. A grant's post for another seat, an older generation, a launch that hasn't registered yet, or a revoked grant is refused and records nothing, so a seat with no accepted post stays unknown, never idle. Revocation is checked again after the post's body arrives, so a revoke or reissue during the read still refuses it. Only the grant's hash is stored, and issuing a new grant for a seat revokes the earlier one. A separate guest router serves only the hook post and accepts only a grant; a guest-only listener mounts it, and a guest must never reach the daemon's main port, whose other routes don't check the grant. The install-wide token and local hooks behave as before, and this adds no CLI. The runner contract's activity section describes the grant and the boundary.
A new rig's authored pod-level startup actions are now saved at creation beside its pod startup files and kept by `rig export`. Action-only pods keep their startup block, and mixed files and actions keep their original root and order, so a rig created from the export runs the actions its source spec declared. They are kept for export only: what a seat added later inherits is unchanged. Older rigs and pods added by expansion without a saved declaration are not reconstructed; keep the source spec for those. Member-level startup export is separate. Refs #1631.
A person who types an answer in the Slack thread of a decision parked on themselves now has it recorded as the answer, as a click already was. Only a reply written after the decision's current question counts. It reaches the asking agent once as its answer row, and it is stored under the first unanswered question in the same transaction as the resume. The answer row belongs to the current park, so a re-park gets its own answer and an earlier message never stands in. Only the typed path opts in, so a click behaves as before. A replay after the ask closes finds the answer its event returned or was folded into, so it isn't delivered twice, while a genuinely new message is still correspondence. Two follow-ups are named: a click after a re-park is still taken as already answered, and a reply interrupted before it is marked seen can reach the agent once as a message if the decision is parked again before the retry.
On an older Codex configuration that never recorded a choice for OpenRig's own plugin hooks, Codex seats could stop at "Hooks need review" after a restart. Plugin setup now trusts OpenRig's six exact shipped hooks there, as a first install does: at daemon start for the daemon's Codex home, and on a seat's fresh launch for that seat's home. It grants only when no config layer OpenRig reads, profiles included, records any choice for those hooks. It decides that on its own fresh read, and writes nothing if the config changed in the meantime. Any recorded choice, a top-level disable, another marketplace source, a modified bundle or a config it can't extend leaves native review in place. A profile-only disable still gets the declaration grants, and its content approval is withheld. The content approval now hashes the shipped files the cache was just compared against. A resumed seat with its own Codex home keeps the review until its next fresh launch. Refs #17.
A pending decision addressed exactly to human@kernel, when no human is registered and that local address has no transport, no longer raises a failed-wake or unclaimed stuck-sweep finding: it is already in the local attention feed, and the waiting view now names that feed instead of promising the sweep. The waiting projection stays truthful when a stale recovery row exists, and listing reuses its ordered delivery ledgers. Registry errors, any registered human, other or external addresses, human updates and real connector failures keep their existing behaviour. This does not register humans without a connector. Refs #2278.
A kernel Codex seat stopped at Codex's hook review is no longer silent when it holds no queue row and nothing was sent to it. The existing wake-ladder scheduler feeds a fresh, verified observation of that screen into the same per-destination prompt episode that refused wakes already use, so existing routing, deduplication, suspension and custody apply. Stale or unknown observations never start an episode, one unresolved gate alerts once, a fresh clear retires it, and an earlier generic alert gains the hook-review step once without a second delivery. Nothing is approved, typed or configured. Native delivery to a person was not run. Refs #17.
When a Claude Code seat's pane is back at a proven idle shell and its last lines are the shell's not-found error for the launched claude (bash, sh or zsh forms, or a missing-file line naming exactly the launch's executable), the readiness timeout and the resume failure now say the pane shows that claude wasn't found, that an update or reinstall may have been in progress if that line is from this launch, and to relaunch the seat once claude is back. Readiness timing is unchanged, so a launch that recovers still becomes ready. No retry, no update-behaviour change, and no relaunch command is named. Refs #2277.
The capability map's sentence about typed Slack answers now names its exception. On an unparked ask, a typed reply is kept in humanAnswers under the first unanswered question. On a question parked on its own person, a typed reply resumes it but isn't recorded as the answer; the owner receives it as a message instead. Buttons work in both cases.
rig seat handover's help, guidance, skill and lifecycle doc now say --source fork:<seat>, which is what the command resolves; they said fork:<id>. A --dry-run now checks a fork source the same way the real run does (the session exists, has a native conversation id and runs the seat's runtime) and returns the same refusal, instead of accepting a source the real run would refuse. The real run's checks are unchanged, moved into one shared helper; the dry run writes nothing.
Adds regression coverage for the reconcile path in #2279: when a process still carries an earlier conversation in its launch arguments but no current identity report exists, reconcile keeps the resume token missing, reports missing metadata with the manual recovery guidance, and sends no pane input. The launch arguments and process lineage alone do not establish the current conversation. This does not recover the missing token; #2279 stays open.
A later hold notice for a slice viewer now keeps the first viewer failure next to the current confirmation state, instead of replacing it with a generic not-confirmed message. The first failure is stored per member in the follower's existing presentation journal, bound to the exact attachment, saved before the bounded confirmation reads, cleared only on positive verification or a confirmed tile close, and kept while a departed but uncertain tile is still shown. Old canvases stay readable.
The 0.7.6 changelog entry and the capability map now say what slice-follow hold notices actually keep: the seat and phase, and the original reason in the first notice. Later notices for the same seat can show a generic reason, while the original failure remains in the journal. Nothing else changes.
When a Codex seat stops at Codex's hook review because its config enables openrig-core but records no choice for any of the plugin's hooks, the readiness reason now says the hooks include OpenRig's own, that the config records no approval (as after an older install or after continuing without trusting), and how to approve them once in the seat's pane, with the attach command. It also says when the approval is renewed: a later launch on the same plugin version records it, and plugin updates after that renew it. Nothing is granted, written or typed; any recorded choice or unreadable layer keeps the old reason. Refs #17.
A parked-owner reminder rejected because the retained-message quota is full is now recorded as a typed refusal before input, carried through the real transport result into watchdog history, so unchanged quota failures stop repeating attempts. Automated parked-owner reminders skip terminal-only recipients. For an unhealthy blocked obligation whose reminder stops this way, the existing stuck sweep raises one deduplicated recovery finding through its existing routing and backstop; another seat's note on the row doesn't settle it, the owner's response or a real state change does. A reason that only resembles the marker, and attempted, written or unknown input, never counts as a positive no-input refusal. Retained messages, healthy holds and explicit sends are unchanged.
Brings three documentation pieces from the 0.7.6 release line onto main: the changelog's 0.7.6 section, the capability map's 0.7.6 marker and lines (it says mission following is not included in 0.7.6, keeping main's newer mission-following text), and one sentence in the terminal-sessions guide saying viewer-ensure's view_required and unknown_provider now return HTTP 400 where they returned 200 with an error body. Versions, install pins and the version-coupled what's-new note are unchanged.
When `rig queue create` is refused because the new row's human-facing fields are missing or invalid, the CLI now says no row was created, after the request-ID line. Five daemon refusals are raised before the create's first write and inside its transaction: human_route_fields_required, invalid_human_notification, invalid_human_questions, reply_to_requires_update and reply_to_not_found. Any other 400, a remote host's refusal, a timeout or a lost connection still claims nothing about whether a row exists.
When a send is refused because a shell-labelled pane's expected agent can't be verified, the refusal now says what delivery depends on and what to do: verifying the bound pane and its input-owning agent, since a live process alone isn't enough; check what is running with `rig capture <session>`, and if the agent has exited to its shell, start it again before resending. The refusal itself is unchanged: no text or keys are sent and nothing is retried. This is a clearer diagnostic, not a fix for delivery to Pi behind a wrapper.
The shared kernel dashboard's recovery steps now take the terminal's registered session name from `rig ps --nodes --rig kernel --json` (the `operator.human` row's `canonicalSessionName`), and use it in the capture and fresh-launch commands. The checks stay the same: capture the pane before concluding, launch fresh without `--stop` so a live session is never replaced, check the result before repeating, then reopen the view with `rig terminal open saved:kernel --window`. The kernel operator's guidance drops its redundant fixed session name and keeps the same lookup. Neither document hard-codes a session address any more, which package assembly refused.
Reading the tail of a captured transcript with a very long line no longer blocks the daemon for seconds. The backwards reader used to count newlines over the whole accumulated tail after every 16 KiB read, which made a long JSON result quadratic; it now keeps each decoded chunk with a running newline count and joins once after the scan. The complete-line boundary, the UTF-8 rewind and the trailing-newline handling are unchanged, and two tests cover a roughly 1 MB multibyte line with and without a final newline. The author's measurement of reading one line back: 1 MiB from 128 ms to 35 ms, 8 MiB from 6,095 ms to 256 ms.
When a restore finds that a seat's saved conversation is missing, for example a Claude seat stopped before its first message, the seat still pauses at `awaiting-decision`, because OpenRig's records can't tell a never-used seat from one whose conversation lives elsewhere. The pause message now says that, and names the existing steps that start only this seat fresh for a person who knows it was never used: one step (`rig seat launch '<seat>@<rig>' --fresh --reason "<why>"`) when the rollback removed the blank session, and two steps (`rig seat stop`, then the fresh launch) when the restore reused the seat's own pane. The blank attempt is still rolled back, saved tokens aren't changed, and nothing starts fresh automatically.
Adds a draft entry point for people integrating a harness or an execution platform with OpenRig: `docs/reference/author-contracts.md`, with `harness-runner-contract.md` and `execution-platform-contract.md`. It separates what the current source implements from what is proposed or still open, covering launch, identity, input and activity, permission and cancellation, resume, context, placement, owned versus attached resources, shutdown, and artifact return. Existing presets, AgentSpec and RigSpec stay distinct from the proposed HarnessSpec and DeploySpec vocabulary, and no new loader or conformance guarantee is claimed. `community-harness-presets.md` links to the entry point.
A following terminal view no longer leaves an owner permanently unable to join when the view's own checks refuse before any pane split is dispatched. Only that fresh, positively known no-effect intent is retired, and its removal is saved before best-effort directory cleanup, so a cleanup failure cannot strand the join again. A dispatched create with an uncertain reply keeps its provenance, including when enumeration is empty. Hold notices keep the seat, observed phase and original failure across passive confirmation reads, and the seat binding is rechecked before an attach is dispatched.
An interrupted join that stopped before its attach continues through the existing ensure path. An attach that was already dispatched stays fail-closed, including a pane that returned to a shell after its viewer exited; this change does not restart it or any native agent.
When a reply to a shown `member@rig@<self-id>` address matches no registered host, the hint now says how to bind it: run `rig host doctor <alias>` for that machine's entry if it uses http (it learns the id), or set `hostId:` on that entry in hosts.yaml (any transport). Before, it named only `rig host ls`.
On a large database, `rig daemon start` no longer kills its own daemon when startup outlasts the CLI's readiness deadline. When only the deadline has elapsed and the spawned child is still alive, the CLI leaves it running, keeps the startup reservation that excludes a second start, and exits nonzero with a finite pending message. That message names the child PID, the launch's health endpoint and the log, and it says how to restart by hand. Nothing is published as ready.
A child that exits, a native start error, an identity or listener mismatch and owned-child cleanup are handled as before. There are no new error codes or dependencies.
A mission's terminal view now follows the current owners of open work tagged to the mission itself or to any of its indexed slices, with each owner shown once and all their obligations listed. Unavailable owners stay named, and an incomplete read keeps the existing viewers.
When slice names repeat across missions, a row tagged directly to the mission is placed by that tag, including work tagged to two missions. A row reached only through a shared slice name, with no mission placement or a contradictory one, makes the view incomplete instead of empty. The built-in launcher reads the terminal preview for its count, layout and unavailable members, and opens with that preview's plan. An unused review-band seat lister is removed. There are no new error codes or dependencies.
A stopped Claude seat can now be pointed at a new token file and resume its same conversation with it. `rig provider switch --seat <seat> --to-account <label> --token-file <path>` on a stopped seat runs the same checks as for a running one, predicts the seat's next `rig launch` the way restore decides it (including the automatic periodic carry), and saves the reference only when that launch resumes the conversation. Nothing is relaunched and no snapshot is taken. The result names the launch that resumes: `--snapshot-id <id>` for a saved conversation, or a plain `rig launch` when the resume comes through the periodic carry. If no launch would resume, it refuses and changes nothing.
`rig provider precheck` gives the same answer for a stopped seat, and a `--stored-login` switch keeps the warning when an inherited `CLAUDE_CODE_OAUTH_TOKEN` still wins. A running seat is switched as before. The carry's launch-time eligibility check is one shared function used by both the launch and the prediction, and the launch's behaviour is unchanged.
The roadmap's released section now points at 0.7.5: more dependable delivery and recovery, plus a preview of task-following terminal views, with a link to its release notes. Nothing else in the roadmap changes.
When the TUI opens an agent spec without having read the seat inventory, its "seats now" line now says the seats weren't read, instead of claiming there are none. With the inventory read, it lists the seats as before.
MCP `rig_context_get` and `rig_artifact_read` now cut long text between whole characters, so a page never ends or starts mid-character. Pages stay at most 48,000 units, and the next page starts on a whole character.
A pod's Recent tab in the TUI now reads its rig's Recent window once and shows it, instead of "RECENT window not served". The pod's other tabs make no Recent request.
An agent's page in the TUI now reads its seat's recently finished items, so the RECENTLY FINISHED section shows that seat's done work from the bounded window instead of nothing. Other seats' items stay out, and the rig's Table and Graph tabs make no extra request.
An agent's page in the TUI now shows that seat's actual health findings in its HEALTH section, instead of always reading "Not assessed". Agent pages join the existing focused health read, reusing the same health endpoint, and a failed health read still shows as unavailable, never as healthy.
When an ACP turn is cancelled and writing the cancellation note fails, `rig acp run` no longer exits on an unhandled rejection mid-cancel. The cancel completes as cancelled, and the failed note is reported on stderr instead of being treated as written.
A slice whose folder name is reused by another mission can now be followed by its unique id. When the selected slice's name or id collides with another slice's, a queue row carrying the colliding alias is placed by its exact `mission:` tag: it counts only when it names the selected slice's own indexed mission, and rows naming the other mission are excluded. A row with the colliding alias and no mission tag, an unknown or contradictory one, or a placement that names several claimants makes the read incomplete with that row named, so existing viewers are kept rather than retired. A bare selector that matches several slices still reports `scope_ambiguous`, flat legacy slices get no invented mission, and unique aliases match exactly as before.
Release missions commonly reuse template slice names such as `01-regression-checks`, which previously made those slices impossible to follow even through their unique ids. One queue projection and the existing read bounds are unchanged.
A Claude agent can opt out of MCP server loading with `claude_mcp: none` in its declaration. That agent starts with `--strict-mcp-config --mcp-config '{"mcpServers":{}}'` on fresh, resume and fork launches, including when it runs a retained Claude executable. Leaving the field out keeps normal loading; only `none` is accepted, and only for Claude declarations.
The per-seat choice follows saved restores, handovers, shutdown probes and displayed recovery commands, and `rig expand` and add-member carry it beside a member's other settings, including its own `claude_token`. Claude exports and bundles keep it; a non-Claude export names the agent in the existing `# Not carried:` report. Its migration is `142_node_claude_mcp`.
These are offline plumbing checks with simulated provider and tmux observations; they don't establish live Claude MCP enforcement, enterprise-managed MCP behaviour or startup savings.
`rig terminal open slice:<id> --follow --provider herdr` opens one terminal workspace on the current owners of a slice's open work and keeps it as the work moves. Owners are the destinations of pending, in-progress and blocked queue items carrying that slice's exact scope tag; an owner holding several items appears once with every obligation, and an owner who is unavailable stays named.
The slice is resolved from the daemon's configured slice index by its directory name or unique frontmatter id, with no substring matching or first-match: a name or alias claimed by two slices reports `scope_ambiguous`, and an unknown, unsupported or unreadable scope reports its own code. An incomplete read keeps the existing viewers instead of reading as empty; a known empty slice keeps following and repopulates when work arrives. The slice identity is read once per observed scope change (or refresh) and queue ownership is read fresh on each render. Scope views default to four tiles (task views keep eight), and departures close viewer panes only, never agents or conversations.
Previews now carry `task.kind` and `task.id` (task views keep `qitemId`) and `capabilities.ensureViewer`. Ensure for an unsupported view kind now returns HTTP 400 like open and preview. Mission views are not part of this change.
The 0.7.5 section of the changelog now says what the published 0.7.5 release notes say about #2200: the hook-refresh restart was reported to refuse one Codex seat upgraded from 0.6.x, its cause is unknown, and the inspected 0.7.4 check already accepts the reported process tree. It is not fixed in 0.7.5 and is not an established refusal of every staged-script launch. If a restart refuses, nothing is stopped: leave the seat running and report the refusal with CLI and daemon versions. A refused restart does not complete the hook refresh. Text only.
The install pins on main move from `v0.7.4` to `v0.7.5`, now that 0.7.5 is published: the README's `skills add` lines, its install-script lines and the sentence naming the release, plus the same install-script lines in `docs/reference/getting-started.md` and the example tag in `docs/reference/quickstart.md`. `docs/reference/whats-new.md` stays at main's note, because it names `packages/cli`'s version. Text only.
The TUI's rig agent table no longer splits an emoji when it cuts a cell to its column width, and a cut cell keeps the table's columns aligned. Before, a seat whose current summary had an emoji at the cut point printed a lone UTF-16 surrogate before the `…`.
`pad` and `padLeft` in `render.ts`, which build every rig table cell, now cut with `sliceWhole` (as #1453 and #2252 did for other cut points) and pad the result back to the column width, so a cell isn't one column short when an emoji is dropped at the cut. All-ASCII cells are unchanged.
`rig expand` now keeps every member setting a rig spec accepts, including `env.CLAUDE_CONFIG_DIR`, so a new seat uses its selected config home. The HTTP route and the expansion service each rebuilt members from an incomplete field list, silently dropping `env`, Advisor, startup, launch additions and continuity settings.
The route now passes the original member to the service. The service keeps the canonical fields and translates the established camelCase aliases (including `claudeToken` to `claude_token`) before the existing rig spec validation, creation and launch path. Canonical validation stays the one place that judges member fields, so a malformed or unknown setting gets the same validation error `rig up` gives. A provided `model` or `label` must be a string; missing or empty values behave as before. Non-string `agentRef`, `codexConfigProfile` and `restorePolicy` keep their existing string fallback, and explicit effort and alias precedence are unchanged. The single-member add route already carried raw members.
The tests use a fake runtime and tmux adapter, so they check OpenRig's plumbing and stored settings, not a native login.
Main's documentation catches up with 0.7.5's release text. `CHANGELOG.md` gains the `## [0.7.5]` section above `## [0.7.4]`, and the capability map (`packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md`, marker `capability-delta-v0.7.5`) takes the 0.7.5 text. Both are byte-identical to `release/0.7.5-successor2` at `b57c4da3`. The rest of `CHANGELOG.md` is main's, including its newer 0.7.3 corrections.
`docs/reference/whats-new.md` stays at main's note, because the packaged note must name `packages/cli`'s version, and the install pins are unchanged. Text only.
A Claude member can name its own subscription token file in the rig spec, so a new Claude seat with its own config folder starts on that token from its first launch. Before, `rig provider switch --token-file` needed a running seat with a conversation to resume, so a first start took a bootstrap conversation, `set-resume-token` and then the switch.
```yaml
- id: dev
runtime: claude-code
env:
CLAUDE_CONFIG_DIR: /abs/claude-dev
claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
```
When the rig is created, the label and path are stored on the seat as the same reference `rig provider switch --token-file` writes, and every launch runs the existing token step, which reads the file as Claude starts. Validation checks the label and that the path is absolute and on one line; it never reads the file. A missing, empty or unreadable file doesn't refuse the spec: the launch starts with the existing fallback notice. Only `claude-code` members and legacy flat nodes accept the field, and there's no rig-wide default.
The token step, shared with the switch, now suspends shell tracing while it reads and assigns the token. When a Claude settings file would outrank the seat's token (an `apiKeyHelper`, or an outranking settings `env` entry), managed, fresh and resumed launches warn in the switch's words. Environment names that the token step removes aren't reported, and managed settings aren't inspected. Export writes the seat's current reference, leaving out API-key references and seats that select an account home, so a later switch shows there and `--stored-login` removes it. A first-start retry keeps the seat's stored reference.
Not covered: a native first turn on the named account needs a real token, so CI doesn't run one.
The TUI's Health and Config views no longer split an emoji when they cut a row to fit its column. Before, an emoji at the cut point printed a lone half-character before the `…` in the Health summary, the findings table and the Config list. Both views now use the TUI's existing surrogate-safe cut, as other views already do. ASCII output is unchanged.
`rig status` now reports a rig's snapshot age correctly on hosts outside UTC. Snapshot times are stored as UTC without a zone, and the age was computed as if they were local time, so it was off by the host's UTC offset: on an Asia/Kolkata host a two-minute-old snapshot showed `5h ago`, and on an America/Los_Angeles host every snapshot showed `just now` for its first seven hours. Hosts on UTC see no change.
In the browser dashboard, a mission declared complete whose current proof is ready now shows its completion label instead of "declared complete · proof ready", which read as though accepted proof still needed review. Readiness alone completes nothing: active or paused declarations, a missing declaration, and incomplete or mixed proof keep their qualified labels, and a mission without a proof checklist reads as before. Only the dashboard's mission formatter changes; mission status, proof judgments and delivery are unchanged.
Addresses #2230. Thanks to @KM199 for the report.
The getting-started troubleshooting section now explains how to bring back the shared kernel dashboard when the kernel's other agents recovered but its `operator.human` terminal didn't. It shows how to find the terminal's session with `rig ps --nodes --rig kernel --json`, how to check the real pane with `rig capture`, why a stored running row alone doesn't prove the session is alive, and how a one-seat fresh launch restores the dashboard. The kernel operator's guidance says the same, so the operator agent can do it.
It doesn't claim a cause for #2201. A retained `excluded_from_subset` may predate the latest reboot, and #2201 stays open.
Thanks to @skv-io for the field report in #2201.
When a seat's agent exits normally and tmux keeps its pane as a dead pane, OpenRig now reports the seat as exited (`sessionStatus` exited, `startupStatus` attention_required) instead of running and ready. Earlier activity no longer keeps it looking busy or idle, parked reminders built from that activity stop, and the stuck-work sweep sends its recovery finding through the existing recovery floor instead of to the seat that can't receive it. The original work row is kept, and nothing restarts the seat.
The batched tmux pane read now includes tmux's own dead-pane flag. A pane counts as exited only when tmux lists it, dead, in the seat's own session (any window). A stale binding or a failed membership read stays unknown, never exited, so a live seat isn't declared dead. Stored statuses aren't rewritten, and send, launch and recovery order are unchanged.
A watchdog episode that closes now says the seat is no longer observed parked, rather than implying a native restart. Limit: recovery findings that already exist keep their recorded recipient; only new findings use the recovery floor.
`rig seat restart --keep-conversation` now recognizes Codex's npm Node launcher when Linux names its process `MainThread`. That launcher is accepted only when its observed executable is Node and its exact argv names an absolute Codex entry script (basename `codex`, or a path ending in `/bin/codex.js`). Reading exact argv keeps argument boundaries, so a supervisor whose flattened command line only looks like Codex isn't selected for the restart's signal, and a launcher path containing a space still works.
Acceptance of `node`-named launchers is unchanged from main: they need no option parsing, absolute path or exact-argv read, so existing Codex launches keep in-place restart. A restart now counts as stopped only when both the launcher and the native Codex process are gone and the shell is back in front, and KILL is sent only while the same launcher (PID and start time) is still running, so a reused PID is never killed. All other restart checks are unchanged.
This doesn't establish whether #2200 is resolved.
A running Claude Code seat can switch from its subscription to an API key and back with `rig provider switch --to-account <name> --api-key-file <file>` (or `--token-file` for a subscription token), resuming its conversation in its own pane with the same home and launch settings. In-flight work is interrupted. The key is read from a private file, never from argv. Combining `--api-key-file` with an account-home selector is refused; select or clear an account home separately. Codex keeps using its saved native API-key profiles, and shared-home behaviour is unchanged.
Restore keeps a missing classic Claude conversation token only after exact native proof, preserves conflicting hook metadata, and won't resume on an API key when credential loading can't run. Classic credential-preparation errors now return structured failures. A narrow restore rejects a snapshot whose saved node no longer matches a current seat by both ID and logical ID before changing anything. A failed in-place resume keeps the pane and binding without bringing a terminated predecessor back as running.
Three test-stability fixes are included: tmux teardown directory races, a probe server kept alive between cases, and tmux dollar escaping in session names.
Related to #2138, after #2143.
A person whose own script handles acknowledgements can set reactions off, so an emoji added to an ask no longer creates a row that costs the asking seat a turn. `rig slack setup --reactions route|off` saves an optional `reactions` key in `slack-connector.json` (validated on save; a missing key reads as route, so the default is unchanged), and the shared reaction attempt drops reactions when it's off, including one that was retained for retry before the switch, with the same ignored (reactions-off) disposition as a fresh one. Slack still sends the event and the manifest keeps it, so switching back needs no app change. The setup guide says this, and that the setting reaches a running connector when it next rewires (a daemon restart). No flow uses a reaction as an answer; a person answers by typing a reply or with the ask's buttons.
8 files: source (4 files, +20), tests (+35 -4) and docs (+9 -1). One save-time refusal for an invalid reactions value; no new error code.
When Slack posts a reply to a deleted thread as a new top-level message, OpenRig now records it as that conversation's new thread. A person's reply to it reaches the agent that posted it, and the agent's `--reply-to` answer lands in the same thread. The old root's mapping is closed, so later posts don't reuse a deleted thread. A reply Slack threaded where it was asked to is recorded as before, and an answer without the posted message keeps the requested placement.
For a `--reply-to` update that Slack posted top-level, `rig queue create --verify` now reports it as not threaded, with the reason `thread-missing`; retries still use the original choice. A later part of a long message that Slack posts top-level, because the first part's root was deleted between parts, opens a root of its own, so a reply or reaction to it reaches the agent.
Not changed: an inbound row's source footer still omits `thread_ts`, and a delivery that reconciles an earlier ambiguous post still records the requested placement. If writing the placement note fails, `--verify` can still read the update as threaded.
Thanks to @KM199 for the trace, the offline reproduction and the acceptance cases.
A running Claude Code or Codex seat can move to an existing account home with `rig provider switch --account-home <dir>`, or `rig seat set-runtime --account-home` when it also changes runtime. A successor confirms its handover before the selected home and settings are committed, and a refusal before cutover leaves the seat as it was. The seat keeps its address, edges, queue items and claims. An account-home switch starts a fresh conversation with a handover. `--clear-account-home` returns the seat to its configured member, rig, daemon or native defaults; it doesn't restore earlier credentials.
The selection is stored as a path only, in a new nullable `nodes.account_home` column (migration `141_node_account_home`); credential values stay in that home. Reading a Claude settings file for credential conflicts no longer blocks on a FIFO or parses a file over 1 MiB: either reads as unreadable.
Related to #2138. Conversation-preserving API-key switching follows in #2191.
When an ask is parked on the same person it asks, that person's button answer in Slack is now recorded and resumes the ask, as a typed thread reply already did. Before, the click was received, then refused as if the decision were closed, the answer wasn't stored, the asking seat wasn't woken, and the person was told "This decision is no longer waiting for answers", which wasn't true.
A click on a parked ask is accepted only when the park names a person, the clicker is the ask's own destination and not a different registered person, and the click comes after the current human-required post. The check is the typed reply's existing current-question lookup, now shared rather than copied, and a complete answer set resumes through the same resolve step, once, with one answer to the asking seat; a redelivered click changes nothing. A click that's still refused for a parked ask now says the decision is parked right now and isn't taking this answer. Parks on someone else or on another item, closed decisions and stale questions are refused as before, and the typed reply path is unchanged.
4 files: source (3 daemon files, +32 -10) and tests (+68). No new refusal, error code or setting.
A model declared for a seat, in the rig spec or with `rig seat set-model`, can reach a community harness whose launcher opts in, so it doesn't have to be set twice. OpenRig says which model it asked for and whether it passed it, and doesn't claim which model actually runs.
A preset may set `passes_model: true` (a new optional boolean, false by default). Its launcher then gets the seat's declared model as `OPENRIG_MODEL`, on new and resumed launches, in the same launch command as the other `OPENRIG_` variables; with no declared model the variable is cleared. Passed means sent: the launch result marks the model's dispatch confirmed only when the launch command's send succeeded, a later failure of the launch doesn't undo that, and startup records `node.startup_model_passed` only then. Typing that began and then failed leaves the dispatch uncertain, not recorded as passed and not typed again; a launch that failed before typing passes nothing. The effective model stays unknown: no model reader is added, and there's no default, substitute or fallback model. Presets that don't opt in, plain presets, built-in runtimes and seats with no declared model are unchanged.
The example launchers map it for a new session only and don't apply it on a resume, where the model a resumed session runs stays unknown (for Cursor it hasn't been checked). Cursor's adds `--model`. Hermes's writes a model line only into an absent or empty seat `config.yaml`; a seat with its own config keeps it byte-for-byte, and the launcher says the model wasn't applied. Both presets ship with `passes_model: false` until checked with the real harness.
The requested / passed / effective split comes from @pdurlej's input on #1597.
15 files: source (5 daemon files, +43 -13), tests (+163 -5) and docs and example launchers (+57 -2). One parse refusal for a non-boolean passes_model; no migration, new error code or setting beyond the preset field.
Two lines in the 0.7.3 section of CHANGELOG.md now say what the published v0.7.3 release notes say. A Codex seat's permission settings were enforced natively in our test with Codex 0.164.0, including after a managed restore and a restart, and other Codex versions weren't tested; the line no longer says enforcement was unproven. The credit for #1679 drops "the issue's main report is still open", since that issue was closed on 2026-10-21.
1 file, CHANGELOG.md, +6 -6. Text only; no release, package or behavior change.
On a Codex seat whose typing guard is draft-aware or openrig-urgent, protected delivery no longer pastes a message into a composer where it can't then confirm and submit it, which left input that later work could collide with. Before the paste, under the target lease and after buffer preparation, it checks that the complete formatted message will be visible. If that can't be confirmed, the message is retained and not sent (`draft_input_unknown`). The estimate and the complete-text ownership reader share Codex's native textarea width (62 columns in a 65-column pane).
A logical line strictly shorter than the native width is charged exactly one row; any other line gets a conservative bound over the whole line, covering native break points and the insertion cursor row, rather than re-implementing native Unicode line breaking. So a message with a line that wraps can be retained where main would have delivered it: at common four-tile sizes, roughly a one-paragraph body over about 115 characters at 80x22, 192 at 80x24 or 526 at 120x30 (examples, not limits). A paste over 1,000 code points is also retained, because Codex 0.164 collapses it to a label that protected ownership doesn't recognize. Recovery is a larger pane or a shorter message; turning the seat's typing guard off turns draft protection off and isn't a recovery. Retained isn't delivered, and nothing is replayed, cleared, force-sent or submitted with an unconditional Enter. The default (guard off) and the full staged-text ownership and human-input checks are unchanged.
5 files: source (4 daemon files, +51 -4) and tests (+217). No new refusal, error code or setting.
A community harness preset can declare `runner: true` when its launcher hosts the agent and reports the seat's turn boundaries, as OpenRig's own Pi runner does. That seat's activity (working, idle, needs input) then follows the runner's reports instead of reading unknown from a quiet screen. Plain presets and the built-in runtimes are unchanged.
`runner: true` needs `launch_result: true`. Each harness launch records the declaration on its session in a new nullable `sessions.agent_runner` column (migration 141), so a later preset edit doesn't change a running seat, a no-launch continuation keeps the recorded declaration, and a session recorded before this reads as plain. A runner-declared session's lifecycle hooks decide its activity, and screen sampling counts only as evidence of work; the hook route's first declaration and the reconcile pass read the same recorded fact, and reconcile re-declares when a same-name occupant switches between a plain and a runner launch. A runner's `Stop/<preset name>_exited` reads unknown, never idle, while Pi, OMP, plain and legacy sessions keep today's mapping. A runner launch whose record never arrives, or whose record folder can't be made, needs attention instead of reading ready. Docs: a "Runner presets" section in `docs/reference/community-harness-presets.md`.
No new endpoint, token or permission: a runner posts to the existing activity hook route with the existing local token. Send readiness and pane input parsing are unchanged.
16 files: source (11 daemon files, +122 -38, one migration), tests (+328 -3) and docs (+40 -2).
When a seat sits at a choice prompt and its row came from another host, the alert about it now still reaches someone, and one such row no longer stops the whole pass. The wake ladder used the row's original sender as a local destination for the alert's carrier row; a sender on another host failed local destination validation and threw, aborting the shared prompt pass, and the same choice in the ordinary ladder failed the whole tick. A shared carrier choice now picks an admissible configured operator or the original sender, otherwise the blocked seat itself, at the prompt pass, its consumed-hop continuation, and the ordinary ladder's continuation and operator-floor alert. The carrier is never nudged, the original sender stays as the row's source, and the existing human-alert policy (including explicit-only) is unchanged.
3 files: source (queue-wake-ladder.ts, +13 -6) and tests (+51). No new refusal, error code, timer or setting.
The TUI's terminal button now opens the scope you're looking at: an agent's page opens just that agent, a pod's page gets an "Open terminals ▸ pod <pod>" button, and the rig's page opens the rig. An agent row's term link and the attention card's chat line now open that one seat instead of its whole pod. The daemon gains a seat:<rig>/<logical-id> terminal view beside pod:, built from the rig's own seat list; an unknown or malformed seat gets the existing view_not_found error, and rig terminal open's help lists the pod: and seat: forms.
11 files: daemon terminal view (+18 -1) with its test (+21), TUI render, attention and link styling, CLI help, and TUI tests; +112 -24 in total. No new refusal or error code.
The README, getting-started and quickstart now install and add skills from the current release, v0.7.4, instead of v0.7.3. docs/releases/README.md says it holds the per-release notes for v0.1.12 through v0.6.3 plus v0.6.12, points to CHANGELOG.md and GitHub Releases for later ones, and its release steps match how releases are published now. Shipped skills and the kernel culture no longer route to a skill that isn't shipped: rig-lifecycle points to rig context get help, the kernel culture's restart case points to getting-started's "Incomplete setup and restart", and the dead sibling entries are dropped. Both skill copies stay identical and their digests are updated.
12 files, +28 -56: docs, skill text and the digest file only; no code change.
A queue item created while its recipient sits at a selection prompt now gets its original wake retried once the prompt clears, even after a daemon restart or when an idle hook masked the pane's attention state. A definite no-write prompt refusal arms recovery; the first positive clear can retry the durable failed wake intents, reusing the same row and frozen wake body. Delivered and indeterminate wakes are never re-sent. Each clear pass holds per-row reservations, so overlapping passes can't release each other's rows or re-arm a refusal. For #2219.
3 files: source (queue-repository.ts, startup.ts; +44 -16) and tests (+107 -5). No new refusal, error code, timer or schema change.
Node inventory now reads each node's activity by the node ID it already has, alongside the current session name, instead of resolving the name back to a node, and attention enrichment no longer repeats a hook read whose result was discarded. A newer same-named session on another node can no longer supply this node's activity or attention state. No-session rows and the standalone attention fallback keep their behavior. In one matched measurement (15 rigs, 75 nodes) the change removed 900 SQL calls per command and cut the CLI median from about 1,028 ms to 622 ms; no general speedup is claimed.
3 files: source +6 -5, tests +71 -1. No cache, schema or API change.
Queue wakes from the daemon no longer arrive as "From: <unknown sender>". A wake-ladder retry now names OpenRig (daemon@kernel) as its sender and keeps the row's own source visible in the body ("Row source: <seat>."), and a workflow's first entry wake names the session that started the workflow. Retry timing, caps and admission are unchanged. For #2223.
4 files: source (queue-wake-ladder.ts, workflow-runtime.ts; +7 -2) and tests (+54 -3). No new refusal.
Codex resume identity stays correct when a supported profile or working directory contains spaces. The daemon now reads each foreground Codex process's exact arguments from the OS (Linux's existing /proc sample, bounded macOS KERN_PROCARGS2) instead of splitting flattened ps text, so a valid managed resume isn't rejected and a UUID inside an option value isn't taken for the resumed conversation. Resume and fork proof, restart in place, permission recovery, effort and claim still require exact arguments, and report unknown or unverified when they can't be read. Ordinary sends, queue nudges and watchdog wakes still reach a stable foreground Codex whose arguments can't be read, without treating it as identity proof.
35 files: 9 daemon source files (+286 -52) and 26 tests and helpers (+639 -147). No new public refusal code, migration, dependency or public API change. The macOS argument helper runs once per foreground sample, uncached.
A seat whose model was changed with `rig seat set-model` keeps that model when it's launched from a snapshot restore: restore now reads the seat's one saved model, as the live profile already does, for both the resume and the fresh restore paths, instead of the snapshot's older model. A seat with no saved model still gets the snapshot's. For #2224.
2 files: source and tests (restore-orchestrator.ts and its end-to-end test).
`rig expand` with an explicit effort for a new member now keeps it, instead of silently launching at the default: the expansion route and the expansion spec both carry the member's effort as written, and the canonical RigSpec schema still trims a text effort and ignores any other value with an advisory, as before. No new refusal.
4 files: source +5 -0, tests +58 -0.
`rig chatroom watch --tmux` works for any rig name: the watcher goes to tmux as separate arguments after `--`, with no shell; a final `;` is escaped so tmux doesn't split the command; and the session name is encoded for tmux's format. The command prints the session name tmux reports, since tmux versions differ in how they rewrite characters such as `:` and `.` in a name. Finishes #1415 by @rudycelekli.
2 files: source +23 -4, tests +85 -0.
Co-authored-by: Rudy Celekli <[email protected]>
Per-rig node inventory now reads startup-proof history only for that rig's current nodes, scoped by node identity rather than the events' own rig id, since a node's history can predate its current rig. The per-node fold and the inventory it produces are unchanged, and other callers keep reading all history. In one matched private measurement (75 nodes, 30,000 proof events, 15 rigs) proof rows read fell from 480,000 to 60,000 and the CLI median from about 1,973 ms to 1,135 ms; a single-rig control was slower, so no general speedup is claimed.
3 files: source +10 -5, tests +35 -5.
The nodes inventory route checks that the rig exists with one bound query instead of loading the rig's whole graph and discarding it. A missing rig still returns the same 404, and the inventory and its enrichment are unchanged. In one matched private measurement the route's SQL statements fell from 478 to 401 (1 rig) and from 688 to 583 (15 rigs); no general speedup is claimed.
1 file: +2 -1.
Git over SSH inside a managed seat can now reach the daemon's ssh-agent for a passphrase-protected key: the daemon forwards its own SSH_AUTH_SOCK through the shared launch environment to every managed launch, and Pi and OMP pass it to the agent process. An empty daemon value counts as unset, so a socket only the seat's terminal has stays out. rig-spec.md says the socket is forwarded and how to check it with ssh-add -l. Finishes #1162 by @akushonkamen, for #1159.
5 files: source +7 -1, tests +18 -10, docs +3 -3.
Co-authored-by: akushonkamen <[email protected]>
Context section addresses now ignore headings and code fences inside HTML comment blocks, following CommonMark's comment blocks. Commenting out an old section no longer truncates the section before it, creates a duplicate address, or hides every later section. None of the 245 commented markdown files in our own packs changes.
3 files: source and tests +61 -1.
A slice's mission is now found when the missions root is given with a trailing slash or as a relative path: the slice path is resolved once against the root, and a path outside the root falls back to the main walk as before. Nothing else in scope resolution changes. Finishes #1468 by @rudycelekli.
2 files: source +14 -4, tests +37 -0.
Co-authored-by: Rudy Celekli <[email protected]>
A Claude permission notification whose dialog was never shown no longer leaves the seat marked as needing input until the next `Stop` or `UserPromptSubmit`. The openrig-core plugin now also relays `PostToolBatch`, and a batch settles only its own actor's strictly older permission marks once every tool in it has run, so a real pending prompt from another actor or a later batch is untouched. A Claude Code without `PostToolBatch` behaves as before.
11 files: source +144 -29, tests +431 -2, docs +21 -1.
A scenario that uses a step verb with no runtime binding (`restore`, `mutate` or `policy`) is now rejected when the schema loads, with `UNBOUND_STEP_VERB` guidance, instead of running its earlier steps (which can launch real seats) and then failing partway. `emit` stays separately gated and `seed_regression` stays accepted; the runtime check remains as a floor. Test system only. Three parked library scenarios now refuse at load; none is selected by CI.
4 files: test helper and test +37 -9 approx, docs; +62 -9 total.
`OPENRIG_URL=http://host:7433/` and `http://host:7433` now reach and identify the same daemon. The CLI reads the URL once through one shared reader that trims a trailing slash, so client requests, `rig send`, crash-cart, startup proof and the daemon-lifecycle identity checks all agree. Removing a trailing slash never changes a URL's origin, so the stop identity check still can't match a different daemon. Finishes #1462 by @rudycelekli.
8 files: source +62 -18, tests +169 -0.
Co-authored-by: Rudy Celekli <[email protected]>
`rig host doctor` now checks where a reply addressed to a machine's self-id actually goes, using the same lookup `rig send` uses (the registry id, then a declared `hostId`, then the first matching `hosts.yaml` entry), instead of assuming it reaches the entry being checked. A stale alias that wins over a working entry now shows up in the doctor's output. Routing is unchanged, and the new rows don't change the doctor's exit codes: the failing row appears only when the transport has already failed.
3 files: source +68 -14, tests +94 -0.
Slack alert selection now reads the latest owner-notification episode for each active row in one query, and leaves out episodes already posted before it loads full queue rows, instead of two lookups per active row. Bounded by eligibility, not by count: there is no row cap, so no pending ask is dropped, and queue creation order is unchanged. Open asks keep their posted episodes. A failed transition or receipt read now propagates instead of reading as an empty result, and the backlog seeding when a channel is enabled sees the same episodes. No live Slack or large-backlog timing result is claimed.
4 files: source +64 -15, tests +153 -3.
`rig terminal open ... --session <name>` now reaches the Herdr session the daemon is configured for, and never silently another one. A name that matches the configured session keeps its explicit socket; other names resolve separately with no fallback; Herdr's config home and session paths come from the daemon, not the caller. The resolved endpoint is carried through the CLI's follow and ensure paths too, which earlier stopped at the daemon's RPC. The Linux-only wiring test doesn't run in the macOS daemon CI job.
9 files: source +24 -19, tests +174 -10, docs +19 -4.
`rig edge add --help` no longer says both endpoints must be existing logical IDs. It says only `escalates_to` accepts a same-daemon canonical address that resolves when used, with `review-advisor@coord` as an example, and that cross-host and synthetic targets stay unsupported. Help text only; validation and routing are unchanged. Refs #1772.
1 file: +8 -4.
Scenario test fixtures no longer start a Node process for every tmux command. The fixture's tmux wrapper is now a POSIX shell script that keeps the private socket, the saved PATH and literal arguments, and execs tmux, so its exit codes and signals pass straight through. A new forwarding test covers both an exit and a signal with no Node on the saved PATH. Test helpers only; no product code changes, and no production speedup is claimed.
2 files: test helper +6 -11, tests +33 -1.
A scenario action that sets `send.verify: true` now runs `rig send --verify`, so scenario tests check what they say they check. Before, the adapter dropped the flag and ran an unverified send. False or absent values keep the existing arguments. Test helpers only; no product code changes.
2 files: test helper +2 -2, tests +5 -3.
When work owned by a bound terminal seat (`runtime: terminal`, such as a plain shell) is stuck, the stuck sweep's finding now goes to someone who can act on it instead of becoming the terminal seat's own row. It joins the fallback the sweep already uses for an address with no wake route and for an unclaimed hosted member: the operator seat when it's a person, then the row's creator, then the operator agent, and a terminal seat is never chosen. When none of them can be reached, the finding stays with the terminal seat and says why. The typing guard is unchanged. This covers the stuck sweep only; parked-owner watchdog reminders are separate.
2 files: source +11 -5, tests +34 -0.
A same-conversation account switch no longer leaves a Claude seat stopped when its running executable is outside the daemon's PATH. Before the takeover ends the old process, in-place restore reads the executable the seat is actually running, through the existing native witness, and carries it through the classic and managed resume builders; when it can't be read, launch resolution stays as it is today. The takeover also keeps the selected node identity through the guarded TERM, KILL and respawn instead of resolving a bare pane id again, so it can't act on another seat that holds the same pane id. The guard still checks the bound session, pane and occupant before writing. Config-dir selection and the resume token step are unchanged.
13 files: source +58 -25, tests +54 -6.
A cross-host `rig queue create --verify` now reads its delivery receipt from the destination daemon, so a successful remote create no longer reports a 404 and an indeterminate result. Verification reuses the destination reader that create reconciliation already selects, including the registered HTTP endpoint and its bearer, and a host naming the current daemon stays local. The receipt must match the created item's ID, destination, sender and body, so an older row reached by a reused ID can't read as verified. An unreadable or mismatched receipt stays indeterminate, and neither the create nor the wake is resent.
2 files: source +38 -11, tests +90 -0.
In a task-following view, a departed Codex agent's tile now closes once its composer is confirmed empty. Codex 0.164 draws a model and path status row and then a shortcuts row; the capture reader already recognized that two-row footer, but the cursor-bound reader follow uses accepted only one row, so every Codex tile read as unconfirmed input and stayed. The two readers now share the existing footer boundary. Typed drafts, literal placeholders, moved cursors, menus, incomplete frames and ambiguous captures still keep the tile, and only the viewer closes, never the agent. The same reader feeds delivery's draft-aware and openrig-urgent typing guards, so a truly empty Codex 0.164 composer there can now receive typed delivery.
4 files: source +22 -12, tests +94 -2.
`rig terminal open qitem:<id> --follow` keeps one Herdr workspace following a task: an agent that takes an open obligation in the task joins the view without taking focus, and one whose work there ends loses only its viewer tile after a grace period, with the agent and its conversation left running. An explanation strip says why each tile joined or left. A focused tile is kept briefly for reading, typed or unreadable input keeps its tile, and pins, temporary suppression and an eight-tile limit can be changed on purpose. A failed or incomplete read keeps the previous view; a task with no open obligation retires its follower and keeps the workspace. Following wakes on queue, session and node changes and one-shot deadlines, not by polling.
Qualified on Linux with Herdr 0.9.5 (protocol 22). Other platforms and other view kinds keep ordinary one-shot open. Also fixes the viewer inventory call that Herdr 0.9.5 rejected (`workspace list --json`).
21 files, +1265 -44.
`rig send` now waits up to 60 seconds for its result, on local and HTTP sends, plus any `--wait-for-idle` time. A send that answers after five seconds reports its real outcome instead of "Delivery UNCONFIRMED", which led agents to send the same message again. When there is still no answer, the guidance says a busy agent can hold delivered input off-screen, so an empty pane capture does not prove the message was lost: check with the recipient before sending again or filing the same work as a queue item. Nothing is retried, and no refusal is added.
Refs #2195. The send path's own latency on the reporter's machine is not changed here.
3 files, +57 -29 (source +17 -21, tests +40 -8).
OpenRig's own `rig scope slice create` and `slice move` number slices past 99, and the CLI audit has accepted those folders since 1016, but the daemon's scope audit route, which the web UI's Scope pages read, still required two digits and reported each `100-…` folder as a high naming violation. Both audits now use one shared pattern. The conventions doc also says what a slice move does to proof: a judgment binds the slice's path, the item's text and the policy, so moving or renaming a slice reopens its judged items, `rig proof show` lists them, and the scope audit checks structure, not readiness.
6 files, +47 −4 (source +15 −4, tests +26, docs +6). No new refusals.
The roadmap now lists 0.7.4 as released, with a link to its release and its published title, and points readers to the release notes for what shipped, the experimental and security notes for hosted members, upgrade instructions, credits and the remaining testing gaps. It replaces the 0.7.3 section; "How to help", "Testing" and the backlog sections are unchanged. Text only.
1 file, +4 -10.
The Pi terminal UI example said that whether Pi takes a wake and acts on it hadn't been checked. One native check has now run, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in the same session file and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which picked it up unattended. The first seat's task named the commands, so this shows the wake reaching Pi and Pi acting on it, not Pi choosing to park by itself. The same check found two unsafe cases, now stated as observed: with the default typing guard a wake submits a person's half-typed draft together with the wake, and a send while a Pi dialog such as `/settings` is open types into the dialog. The docs say to run the seat unattended, not where a person also types, and promise no remedy. How Pi takes a wake while it's busy, resume and other Pi versions stay unshown. The launcher is unchanged.
2 files, +14 −8. Text only.
The hosted-members reference described how to serve a hosted member over HTTP behind a tunnel or proxy, with no warning about what that exposes. It now opens with why the feature exists (assistants in a vendor's cloud can only reach a server on the internet) and a note: it's experimental, unsupported and off by default; nothing in OpenRig listens on the internet unless you set it up; serving `rig mcp serve --http` behind your own TLS tunnel or proxy with a token is like opening a port in your firewall, and anyone with the URL and a valid token can act as that member within its grant; treat the token like a password, give the member the narrowest grant, and revoke it when you're done with `rig mcp token revoke <name>`; OpenRig reachable from the internet isn't supported.
1 file, +4. Text only.
A running rig's Overview matched a Specs-library template by name, so a Claude-only kernel could appear to use the dual-runtime template, and a project rig outside the library said "NO RIG SPEC". The Overview now reads the selected local rig's existing `/api/rigs/:id/spec` export and labels it current configuration, not a library template. The existing review renderer supplies topology, configuration and YAML; if preview preparation fails, the actual export stays visible. A read failure no longer substitutes a same-named template, and a remote or undetermined host reads nothing local. The Specs library continues to show reusable templates, and nothing changes in the daemon.
3 files, +192 −53 (source +54 −53, tests +138). No new refusals.
Three things a hosted member is told were misleading in a native test, and the member repeated or acted on each. A hosted member with no wake trigger fell through to the general unroutable teaching, "Fix the address, or register the human with `rig gateway human add`". It now reads that the member is a hosted member with no wake trigger, so nothing can push work to it, and that it reads the queue when its owner next starts a session. The result is still `unroutable` with the same prefix, so the stuck sweep still tells the member's person, and an address no roster lists keeps the general teaching. The member workflow, now `member-workflow/v2` and quoted in `docs/reference/hosted-members.md`, says to describe reach from the grant rather than the tool list, and to close a finished item with `no-follow-on`. `rig_queue_update`'s closure-reason description lists the reasons it takes. No new reason is accepted, and nothing else changes.
7 files, +74 −9 (source +40 −5, tests +33 −3, docs +1 −1). No new refusals.
The terminal-Pi recipe said a community preset with `first_input: true` carries the startup pointer in the seat's first startup text file. That's true only for a file declared with `delivery_hint: send_text`: the first such file is folded into the first input after the identity context. A `.md` file left at the `auto` hint resolves to guidance, which a community seat doesn't take, so a person who wrote the pointer that way got nothing but a launch warning. The bullet now says so.
1 file, +3 −2. Text only.
A plain `runtime: terminal` seat running interactive Pi gets no queue wakes. A community-harness seat is an agent seat to OpenRig, so its wakes are delivered to the pane. This adds a copyable example that runs Pi's terminal UI that way: `docs/reference/community-harness-examples/pi-tui/`, with a `pi-tui` preset (`names_conversation` and `first_input`, no `resume`) and a POSIX sh launcher, a section in `community-harness-presets.md`, and stand-in tests against a fake `pi`.
The launcher, written for Pi 1.4.0 from its source, gives each seat its own Pi agent folder and session folder (a leading `~` in `PI_CODING_AGENT_DIR` is expanded as Pi does), copies the installed `openrig-core` skills it's given at each launch, and starts Pi with `--session-dir`, the session id OpenRig named and the first input passed byte for byte after `--`. On resume it opens the one session file whose first line, read as JSON, has the top-level `type` `session` and that `id`, and starts nothing otherwise, because Pi's own `--session-id` and `--session` can create or pick another session. Whether Pi takes a wake and acts on it, resume, and draft safety haven't been checked with a real Pi; the preset and docs say so. No core code, delivery or terminal behaviour changes.
4 files, +260 −1 (launcher +100, preset +14, docs +24 −1, tests +122). No new refusals.
Registering a remote host makes it visible in topology, but its attention items reach FEED only after `feed.subscriptions.<id>.enabled` is set, and nothing told the person so. After `rig host add` or `rig host pair`, the human-readable next steps now include, after `rig host doctor`, the local command `rig config set feed.subscriptions.<id>.enabled true`, using the registry id as given, dots included. An SSH entry is told that remote FEED needs an HTTP entry and how to pair one. An id that feed settings reserve, such as `progress`, gets a different-id suggestion instead of a command that wouldn't work. Registration still leaves the setting alone, and `--json` still returns its one result. The CLI reference describes the behaviour, and the tests run the printed command through the real `config` command.
4 files, +84 −7 (source +18 −2, tests +49 −5, docs +17). No new refusals.
A terminal member defaults to the `sends-only` typing guard, which holds OpenRig's own notices such as park reminders and watchdog messages. The reference didn't say what happens if someone sets a terminal member to `off`: those notices are typed into whatever the pane is running, and a shell prompt runs each line as a command, From and To lines included, because OpenRig doesn't check a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so a shell prompt holds them. The paragraph says this below the modes table and advises keeping the default for a seat that may fall back to a shell.
1 file, +6. Text only.
The terminal-Pi recipe tells Pi to park a CI wait with `--wake-after`, which reads as if the wake will bring Pi back. In a plain `runtime: terminal` seat it doesn't: a parked row's wake isn't typed into Pi, and a handoff to that seat records a failed wake (`failed:terminal-seat`). A native check with Pi 1.4.0 in a real terminal seat showed both. The recipe now says so after its startup pointer, and that the person checks the seat's parked rows (`rig queue whoami --session <seat>` or `rig queue show <id>`) or tells Pi to continue the named row. It makes no claim about community-preset seats and promises no fix.
1 file, +5. Text only.
After `rig attach --self` inside tmux, the session could send with its team address, but a reply to it failed with `transport_unavailable`. The command asked tmux for the session, window and pane joined by newlines and split on them, but tmux writes control characters in that output as `_`, so it returned `name_@2_%2`, the split found no pane, and the attachment silently fell back to the external CLI. It now asks for `#{pane_id} #{window_id} #{session_name}` separated by spaces and reads the two ids by their fixed shapes, leaving the session name as the rest of the line, so names with spaces, underscores or id-like words resolve too. The pane argument is shell-quoted. Outside tmux, a tmux error or unexpected output still gives the external CLI attachment. The tests now render the format the way tmux does, including a real tmux server, and the end-to-end check delivers a reply to the original pane.
3 files, +78 −19 (source +10 −9, tests +68 −10). No new refusals.
A row created before its seat's pane was bound records its wake as unroutable, and the stuck sweep then reported an undelivered wake that nothing retried: the seat spent a turn on a false finding, and the row's own wake was never sent. Now, once that destination has a terminal binding, the sweep delivers the row's own durable wake intent instead of reporting it: it claims the intent, sends, and settles it delivered together with the row's result, so the outbox drain doesn't send the wake again, and it sends nothing when the intent is already sending or delivered. A failed send is still reported on the next tick, a row whose result didn't change is reported as before, the retry ladder and quiet hours are untouched, and an address that still has no binding is reported as before.
2 files, +79 (source +10, tests +69). No new refusals.
rig transcript --tail and rig capture --lines now send the same line count on every path. A count of 0, a negative number or a non-numeric value falls back to the documented default of 20 instead of reaching the daemon as zero, so rig capture --lines 0 shows the default view (20 history lines plus the visible pane) rather than the visible pane alone, and rig transcript --tail 0 returns the default 50 lines. A positive count is unchanged. Over SSH, a value parseInt reads as a positive count is forwarded as typed, so the remote CLI applies the same rule, and the local, cross-host HTTP and SSH paths agree.
6 files, +70 -4 (source +13 -4, tests +57). No new refusals.
Someone submitting a team to openrig.dev/rigs now learns before they submit that the registry's check runs rig bundle check on their folder at the submitted ref with the latest published OpenRig, and that any finding fails it. An exported team has no README of its own, so the publishing guide's submit section and the rig-bundles-and-shareable-artifacts skill now say to add one, declare it in rig.yaml docs, and run rig bundle check at the commit being submitted. The skill gains a short section from an exported team to a submission; its mirror and edge digests are regenerated.
4 files, +29 -2 (docs and skill text only). No behaviour, flag or check changes.
rig terminal open no longer counts a seat as opened when Herdr can't confirm its pane. When Herdr accepts a layout without a tab ID, or its pane list can't confirm a requested seat, that seat is reported as degraded with code pane_unconfirmed and a note to inspect before retrying. Confirmed seats still count, and a partly confirmed open still succeeds; an open with no seat confirmed returns ok false and a nonzero CLI exit. The CLI, the TUI's notices and the desktop failure path name unconfirmed panes separately from skipped seats, and unconfirmed panes get no restart-command report.
Labels are matched honestly. A listed pane counts for one seat at most. When a listed label could belong to more than one seat label (an exact label that is also a prefix of a longer one, or a truncation), those seats are confirmed only when the exact listed labels account for every candidate's seats; otherwise they read unconfirmed, and an undecidable pane never makes a seat read exited. Healthy paging, focus, starting-tab protection and workspace reuse are unchanged. Healthy provider fixtures now return real tab IDs and pane inventories.
15 files, +331 -84 (source +48 -28, tests +270 -56, docs +13). No new refusals; the new structured value is degraded[].code "pane_unconfirmed".
Main's documentation catches up with the accepted 0.7.4 candidate's release text, and its install pins point at the latest published release. The CHANGELOG gains the 0.7.4 section and the capability map its 0.7.4 teaching, both identical to the candidate's; getting-started's reboot troubleshooting row now says a kernel that was already down before the reboot stays down and names rig up kernel --existing, while main's newer rig start --last text stays. README, getting-started and quickstart install pins move from v0.7.0 to v0.7.3, the latest published release; the move to v0.7.4 follows at publication. whats-new stays at main's 0.7.1 note, because the packaged note must name the CLI package's version, and package versions, the lockfile and the packaged-plugin fixture are unchanged.
5 files, +383 -14 (docs only). No behaviour, flag or check changes.
Someone running interactive Pi from a terminal seat, or from a community preset's launcher, gets a written recipe for giving that agent OpenRig's coordination skills, which OpenRig doesn't install for those seats. The new section in the community harness presets guide copies whole skill directories from the installed openrig-core plugin into the seat's Pi agent directory, and puts one startup line in the seat's first turn that names the queue-handoff skill and carries the CI-wait instruction (rig queue block with an external blocker and a wake). Managed Pi seats and Workshop's pinned copies stay on their own routes, and the section says a copied folder and a startup line don't show that Pi discovered or follows the skill.
1 file, +43 (docs only). No behaviour, flag or check changes.
The ntfy and webhook notification adapters now cancel each response body they don't read, right after reading its status, so a slow or streaming endpoint no longer holds a connection open until garbage collection. send() still decides success or failure from the status alone and doesn't wait for the cleanup; a cleanup error is ignored and can't change the result. The new tests use a loopback server that holds the body open and assert that the server sees the response close, plus a rejecting cleanup that leaves the result unchanged, for both adapters and both a 200 and a 503.
3 files, +50 (source +4, tests +46). No new refusals.
The compaction receipt tests no longer fail when a scheduler pause uses up the fixture's 20 ms idle budget. The enforcer and the transport's injected clock read the fixture's stepped clock while the transport's idle deadline read the real Date.now and slept on real timers, so a pause could turn an idle first submission into not_sent before a cancellation assertion expected submitted_unconfirmed. The fixture now shares one clock for Date.now, the injected now() and the transport's sleeps, as production does with real time; the receipt helpers restore only their send spy, and afterEach restores all mocks. The test-layers guide documents the clock boundary. Production code, the 20 ms budget and the assertions are unchanged; real-timer deadline coverage stays in the transport suite.
2 files, +15 -9 (tests +10 -9, docs +5). No production change.
After rig host rename, the TUI's Topology explorer and host pages show the configured name beside the host's identity, for example "Mac mini 2 (mac)". The TUI reads the name the daemon already serves and keeps it separate from the identity used by commands, navigation, row selection and local reads, so a rename stays stable even when the name matches a registered remote host's id. Control and bidirectional-text characters are left out of the label, and the name is cleaned before the default check: the daemon's default name, an absent, blank or matching name, or one that becomes the default after cleaning, shows the identity once. Addresses the remaining local-name item in #2049, following #2052.
8 files, +154 -15 (source +58 -10, tests +85 -5, docs +11). No new refusals.
An explicit rig down now records the stop for a rig whose latest seat sessions are blank rollback sessions left by a failed start, once their tmux targets are confirmed absent, so rig start --last keeps that rig down. Before, rig down marked only exited or detached sessions, and a rolled-back start left the rig eligible for revival. Genuinely live or racing sessions are never marked, and crash recovery for rigs that weren't explicitly stopped is unchanged. The node inventory reports such a recorded stop as stopped on purpose.
rig start --help and getting-started now say which rigs --last brings back: a rig comes back unless every seat it launched was stopped on purpose. A rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or were lost without rig down, counts as last running, and running rig down <rig> once (by id when names are shared) keeps it down. --last never selects the kernel; under --no-kernel its automatic restore is skipped too, and rig up kernel --existing brings it back.
6 files, +83 -7 (source +23 -7, tests +50, docs +10). No new refusals.
rig ps (through --full and the API) now reports periodicSnapshotActive as true only for rigs the periodic snapshot scheduler actually snapshots: not archived, with at least one node whose newest session is running. Stopped, zero-seat and archived rigs read false. The scheduler's running-rig query is exported and shared, so the flag and the scheduler can't disagree; the scheduler's own selection is unchanged. Following Christian Todie's change in cerebral-work/openrig PR57.
3 files, +80 -18. No new refusals.
The queue-handoff skill now says how to wait on something outside the queue, such as CI or a long job: park the row with rig queue block <id> --on external:<what> --wake-after <duration> --continuation "<what resumes>". The row stays yours, reads parked rather than stalled, and the timer wakes you. Text only, with the skill digest regenerated.
2 files, +6 -2.
docs/as-built/test-layers.md now matches scripts/run-pr-scenarios.sh and the CI README: the installed-scenario job starts fifteen fresh containers over nine case options (two baton cases three times each, the refocus-parked effect case three times, and six passing-only cases once each), and the --case and --mode options list them all. Text only.
1 file, +14 -9.
A task preview can report a verified provider terminal mapping, and an owner can ensure a missing viewer through POST /api/terminal/viewer/ensure without opening another whole-team layout. The operation re-derives current membership and the preview precondition, serializes creation per endpoint, reuses the returned pane and terminal pair, and records presentation provenance before any effect; a lost reply is inspected before any repeat, and an uncertain or changed identity stays unconfirmed. It doesn't relayout, close departed viewers, take over control, or start or stop agents. Writable shared tile and recovery attaches add tmux's ignore-size flag (read-only attaches keep -r), and CLI window reuse recognizes both writable attach spellings.
The existing open path keeps its current results and accounting: a stricter accounting of id-less or unconfirmed opens is deferred to its own change, with its consumers updated together. Strict verification applies to the new ensure and lookup paths.
19 files, +651 -27 (source +296 -9, tests +321, docs +34). No new refusals.
An existing stuck-sweep finding now closes only on the source's delivery, pickup or explicit custody evidence. A failed wake becoming unconfirmed, a fresh human-notification post window, an unreadable registry or a larger reporting-age threshold no longer closes a finding just by removing it from a report. Claimed items returned to pending take part in the existing stalled-pickup derivation, and human notifications stay separate from agent wakes.
Older human notices without owner-notification history can close on their item-scoped posted receipt when the recipient is known to be human and unchanged: a destination reroute, or a park whose person changed, needs a new matching notification episode, since the old receipt carries no recipient key. Closure notes name the evidence and record stuck-sweep@system as the actor, keeping finding ownership. Refs #1419, following #1449 and #1811; addresses the false findings reported in #2151.
5 files, +515 -19 (source +91 -8, tests +376, docs +48). No new refusals.
A fresh in-place Claude handover now sends its context packet only after it sees Claude's own drawn input frame on the current screen and the matching launch process, stable across the existing process observations and pane checks. Retained scrollback that looks ready, or a shell prompt line that starts with the same prompt glyph, no longer counts. If that proof doesn't arrive within the existing readiness timeout, the handover stops with a clear message and commits nothing.
Ordinary fresh starts, adopted seats, resume and fork keep their readiness paths, and visible gates and identity conflicts keep their specific results. Refs #1544, following the current-screen qualification in #1553.
6 files, +237 -54 (source +43 -19, tests +157, docs +37). No new refusals.
rig mcp serve with a --port outside 1-65535 (including 0) now prints one warning to stderr and still starts, so an MCP host can connect and list tools; only the daemon calls fail, as before. Nothing goes to stdout, the MCP protocol channel, and no other port is substituted. A non-numeric --port is still refused, and an empty --port keeps using the daemon's own port. This finishes #1416.
3 files, +102 -1 (source +4 -1, tests +98). No new refusals.
Co-authored-by: Rudy Celekli <[email protected]>
A send refused because of the target seat's runtime state (target_runtime_conflict, target_runtime_not_running, target_runtime_unverified) now returns 409, like the other seat-state refusals, instead of 500. When the refusal is a stale bound pane, it ends with the repair: "If the seat was resumed by hand in its session, run: rig reconcile-session <seat>". No text is typed in any of these cases, and rig send --json now exits 1 for them rather than 2.
3 files, +30 -2. No new refusals.
A rig's top-level seat has no delegation parent, so its recovery requests used to fall back to the operator or to itself. The wake ladder and the stuck sweep now honor a declared escalates_to destination, including a seat in another rig on the same daemon, and explain unavailable, ambiguous, self, cycle and unknown-topology fallbacks. Existing delegation parents keep precedence; named targets are kept as topology declarations through import, export, edges and snapshots, and resolved when used. Delivery is one hop per recovery handoff chain, and a consumed recovery uses its own operator continuation without suppressing unrelated work at the same destination.
An already-open recovery keeps its recorded owner even if the originating seat later gains a delegation parent or a changed escalation target; its wake still goes to that owner. Queue waiting and backstop views use the same resolution as delivery.
Implements the scope agreed in #605.
24 files, +1096 -105 (source +364 -90, tests +687, docs +45). No new refusals.
The roadmap now lists 0.7.3 as released, with a link to its release and its three themes (participant foundation, attributed shared-channel conversations, actions and perspective for the primary user), in the same shape as the previous release's section. The active section is removed; "How to help", "Testing" and the backlog sections are unchanged. Text only.
1 file, +3 -9.
Terminal preview and open accept qitem:<id> and show the current owners of that task's active work: pending, in-progress and blocked obligations through explicitly linked descendants, excluding closed predecessors and historical senders. Stopped, missing, external and unresolved owners stay named, and responses say which qitems contributed and report incomplete lineage instead of silently truncating it. Preview and open also return viewDescriptor {kind, name}, so a client can tell a following view from a fixed saved layout.
This derives membership only: no provider terminals are provisioned, no existing workspace is changed, and no agent lifecycle moves. Task views keep the read-only policy of derived views and grant no input. Lineage reads are bounded by explicit row, byte, link and per-record limits, using existing indexes; reaching a limit keeps the known members and marks the view incomplete. No new schema or index.
8 files, +591 -6 (source +261 -6, tests +285, docs +45). No new refusals.
The quickstart's "Learn it in this order" links to the onboarding page and the CLI reference are relative, so a reader opens the version they're reading (GitHub resolves them per ref, and the website rewrites them to the release tag) instead of main. Text only.
1 file, +2 -2.
When daemon start skipped the kernel on purpose, `rig daemon start --wait-for-kernel` now says why and how to bring it back, with the same message `rig start` gives (one shared helper), instead of a timeout header and an unrelated sign-in hint. The exit code stays 1; the timeout and failure states keep their messages.
4 files, +79 -10 (source +20 -10).
The compaction restart fixture gives transport deadlines and the enforcer's simulated sleeps one shared clock (`Date.now` follows the fixture clock), so a simulated wait no longer uses up real preparation time. The spy is restored by the existing cleanup. Test only.
1 file, +2 -0.
The portability report passes explicit `a/` and `b/` diff prefixes to its own `git diff`, so a user's `diff.mnemonicPrefix` setting no longer changes the file names it reports. The test covers both mnemonic-prefix settings with both `core.quotePath` settings. Default-config output is unchanged.
2 files, +4 -3.
The seat-launch environment test compares the paired CLI symlink's target by its canonical path, the same representation the launcher follows, so it passes where the temporary directory resolves through a symlink. Test only.
1 file, +2 -2.
The queue-handoff skill's writing paragraph is followed by its qualification: it's writing guidance, not a delivery filter or length limit. Uncertainty and limits that affect the action stay in. Early references, acknowledgements, requested progress updates and completion returns remain fine, and the sender judges how much context is useful. The skill's digest is regenerated. Text only; no behaviour change.
2 files, +3 -1.
A kernel that was already down before this daemon start now says why it stayed down, and names `rig up kernel --existing` to bring it back. `rig start`, `rig daemon start --wait-for-kernel` and `/api/kernel/status` show that reason; every other skip keeps `kernel rig already managed`, and `rig start` no longer gives the `--no-kernel` explanation for this case.
The reconciler records a detached session right after its transaction commits, before notifying, so a failure in the event bus's own post-commit work can't leave it uncounted.
6 files, +42 -5 (source +12 -4).
The shipped `queue-handoff` skill gains one paragraph on writing a handoff the receiver can act on after one read: open with the request or change that affects their next step, use full words, keep the limits that change that step, put reference IDs after the action, and put the complete request near the start of the task body. The skill's digest is regenerated. Text only; no behaviour change.
2 files, +3 -1.
CLI source comments drop internal tracking references. The daemon's copies of the four shared scope classifier files get the same comment edits, so the CLI and daemon copies stay byte-identical. Comment changes only; no behaviour change.
14 files, +24 -28.
`rig mcp serve --http`'s external grant options take names that say what they do: `--grant-service <url>` and `--grant-service-header-file <path>`, the answer schema `openrig.grant/v1`, and the recorded principal source `grant-service`. An external caller's access and scope use the same wording.
Unchanged: the key and instance patterns, the request and answer fields and statuses, the 503 `grant_authority_unavailable`, every refusal and its code, and the OAuth discovery paths. No old name is kept as an alias. A grant service and an OpenRig on different names fail closed with the existing 503. Rows already recorded keep the principal they hold, because stored principals are read as text and never re-validated.
Restart a running `rig mcp serve --http` together with the daemon. An adapter from before the upgrade keeps working until the grant service's cutover, but its requests are recorded without the grant principal.
14 files, +297 -287 (source and docs +136 -130).
Tests and one old release note use documentation addresses: `192.0.2.10` (RFC 5737), or a tailnet-range example where a test checks that range. One fetch-guard message no longer names an address. No behaviour change.
8 files, +23 -23.
Source comments in the daemon, TUI and UI packages drop internal tracking references, and the regenerated skill and context-pack mirrors follow. Comment and text changes only; no behaviour change. The scope classifier files are unchanged here, so their CLI and daemon copies stay byte-identical.
54 files, +114 -114.
Test fixtures use a synthetic tailnet name in the browser-boundary tests and a synthetic tailnet-range address in a posture test, keeping each test's address class. One fixture uses a non-dated example id. Tests only; no behaviour change.
4 files, +7 -7.
Daemon start brings back a kernel only when its own startup reconcile found that kernel's seats newly detached, as a reboot or crash leaves them. A kernel that was already down before this start, because an earlier start found it down and left it, now reads stale and stays down, with the reason in the boot log. Before, a later start restored it from an old snapshot and offered to resume its old conversations, even months later.
Startup reconcile reports the sessions it marks detached, and kernel boot receives them. A running kernel, one stopped with `rig down kernel`, an unclaimed one and `--no-kernel` are unchanged.
7 files, +70 -16 (source +24 -7).
The changelog and `rig mcp serve`'s help describe the HTTP grant option by what it does: the not-in-this-release line reads "grants from an external grant service for `rig mcp serve --http` (#2096)", and the help text and startup message say each request needs a key the grant service issues. Flag names, behaviour and the OAuth discovery help are unchanged.
2 files, +8 -8.
An OAuth-capable MCP client pointed at `rig mcp serve --http` can now learn how to authenticate from the server itself. With `--oauth-resource <url>` and `--oauth-issuer <url>`, given together and only alongside the external grant service flag, the server answers `GET /.well-known/oauth-protected-resource<resource path>` with the resource, its authorization server, the `openrig.work` scope and header bearers. That happens before the `/mcp` 404 and before any credential or grant check, whatever the Host. Its 401 names that document in `resource_metadata`, and adds `error="invalid_token"` only when a bearer was presented and refused.
Both values must be https, with no userinfo, query or fragment, written exactly as a URL writes them, and the resource path must end in `/mcp`. Anything else is refused at startup, so nothing is rewritten on the way out. Without the two flags, every response is as before. The key, the grant check and the caller's binding are unchanged, and there is no root document, `/mcp` alias or per-tool scheme. The issuer is served exactly as configured, so it should match the authorization server's own issuer byte for byte.
4 files, +186 -11, of which 109 are test lines.
The leak guard's term lists now come from an optional local rules file instead of a file tracked in the repository. Set `OPENRIG_LEAK_GUARD_RULES` (or pass `--rules`) to an absolute path, and every reader uses it through one loader: the leak guard, `build-package.sh`, the context packs, the skill mirror and the control-plane generator. The publishing rules, the frontmatter keys and the internal-section fence stay in the repository unchanged, so mirrored skills are the same.
Without the file, a contributor's `npm run build:package` still works and prints that the term checks were skipped. The release substance gate refuses to run without a rules file, so a release can't pass with the checks silently off. The tracked rules file is removed, and the tests write their own fixture rules.
16 files, +227 -244.
The review page's agents band and roster tell the truth about what a seat holds. Only open rows (pending, claimed, in progress or blocked) count as held, so a seat's handed-off and done rows no longer show as holdings. When the seat has in-progress work, that's what it's doing. A seat reads as parked only on a blocked row that names a blocker, and only when nothing is in progress, so a closed row's leftover `blocked_on` no longer marks it parked.
The roster's "recently holding" complement uses the same set, so a seat whose rows were all handed off still appears, holding nothing. The other readers of `ACTIVE_STATES` (needs-you and the slice phase) are unchanged.
Reported by @ziadeh.
2 files, +37 -10, of which 24 are test lines.
The TUI lists a host's rigs by name again, in the same order as `rig ps`, in the explorer sidebar and the host views. Before, it listed them in the order the daemon's rig summary serves them, which is creation order.
`hydrateSnapshot` sorts each host's rigs once, where the snapshot is built, comparing names the way SQLite's `ORDER BY r.name` does, so uppercase sorts before lowercase as in `rig ps`. The sort is stable, so two rigs with the same name keep their creation order. A drilled-into remote host's rigs are sorted the same way. Pods, seats and the daemon are unchanged.
Reported by @duncanleung.
3 files, +37 -17, of which 29 are test lines.
An assistant connected to an OpenRig team over the MCP server now learns from the connection how to work: its inbox is the items addressed to its seat, a wake only points at the inbox, and it claims an item, does it, then closes it with the answer or hands it on. When an item needs a person's approval first, it keeps the item and asks instead of closing it or acting anyway: it blocks the item on the person who sent it, with the question and a short form of what it will do as the summary, or, for an item from a seat or a shared channel, asks a seat as a new item and blocks on that. The item reads blocked while it waits, and the same item continues after the answer. Both paths are the queue's existing behaviour; the guidance was missing, not the mechanism.
The guidance is one versioned text, member-workflow/v1, joined into the server's MCP instructions, and docs/reference/hosted-members.md quotes it word for word, with a test keeping the two identical. rig_queue_update's blockedOn and rig_queue_list's owned descriptions say the same. A hosted member with no node or session can call rig_whoami and learn the seat it acts as; before, that call failed. A caller connected through an external grant service asks the team for work rather than being a member, so its instructions keep only the shared lines. No new refusal, prompt or required step.
4 files, +154 -10, of which 98 are test lines.
An OpenRig Cloud customer's connected assistant now hands its tasks only to the team it was granted, never to its own caller seat or another customer's caller seat. A customer's `rig_queue_create`, or a revision of one, addressed to a caller-shaped seat answers `out_of_scope` with the same message a customer already gets for a person's or another host's address, and nothing is written.
The change is one line: `customerScope`'s `destination` uses the existing `team` predicate (the grant's member seats minus the caller and any caller-shaped seat) instead of `member`. Self-hosted grants, the customer's show, list and artifact reads, the provenance-tag filter and every other tool are unchanged.
3 files, +32 -6, of which 25 are test lines.
When an agent asks a person a follow-up about a customer's Slack message, the person's Slack post now shows the customer's words and the agent's question without OpenRig's agent-only context: the routing footer, a shared channel's envelope (who wrote, the organization, its readable policy) and the attachments block OpenRig generated (workspace-local paths or transfer-failure diagnostics), in the blocks, the fallback text and long-ask parts alike.
The footer and envelope are cut by their last occurrence, since OpenRig always adds them after the sender's words. The attachments block is cut only by the length inbound recorded when it actually generated one (`slack-attachments:<n>`), at a section boundary, starting with OpenRig's own heading. And the projection runs only while the row's body still matches the digest the gateway recorded at creation (`slack-body:<hex>`): a body a handoff replaced or extended is shown whole, so a person never loses authored words. Look-alikes typed by a customer are kept verbatim. The durable row body, stored files, summary, thread, alert and mention are unchanged; there is no new refusal, setting or migration.
Slack messages received before this change carry no digest and are shown whole when later parked to a person.
3 files, +279 -6, of which 223 are test lines.
When an agent is down at a periodic snapshot tick, the snapshot now keeps that agent's last resume source, so a later `rig launch <rig> <agent>` resumes its conversation instead of coming back `awaiting-decision`.
The snapshot carries the agent's newest session only when it has a resume token and a `resume_if_possible` policy, and one source is chosen before FR-7 and passed into `postLaunchRestore`; the carry is revalidated before any launch effect. It applies only to a named agent's automatic periodic restore: an explicit `--snapshot-id`, `--fresh`, a whole-rig restore and `selectedHistory` keep their meaning, an agent without a token still starts fresh, and 0.7.2 still reads the new field. The idle-shell message points to the command that actually resumes.
Reported by @pr0xc3nt4ur1 in the linked issue, and built by @dajiaohuang, who traced the restore path past the early refusals first.
8 files, +407 -21, of which 290 are test lines.
When an OpenRig Cloud customer hands the team a task and the team later asks a person about it, that person now sees the customer's whole text. Before, a customer could tag its row with OpenRig's own Slack provenance tags, or with the workflow sign-off tag `gate`, so the person-facing view showed only part of the row, or only its summary, while the agent read the whole body.
The MCP server's `rig_queue_create` now drops, for a validated Cloud customer grant only (create and revision), the tags `founder-slack`, `inbound`, `shared-channel` and `gate`, and every `participant:` and `slack-` tag. The customer is identified only from the validated grant. The body, summary, other tags (including `workflow`, `workflow:` and `instance:`) and the request's success are kept; local, stdio and token-file callers and Slack ingestion are unchanged; there is no new refusal.
3 files, +80 -2, of which 71 are test lines, including real daemon routes where the person's render keeps the full body.
An OpenRig Cloud customer's caller seat that was created ahead of time but never launched now works. Before, the MCP HTTP server asked the daemon's `whoami`, which knows only seats that have run, so a pre-created seat was refused with `seat_unavailable` before any tool.
Now, only when `whoami` answers 404, the server reads the daemon's own rig and node lists and serves the seat when exactly one existing node's canonical name equals the seat the Cloud grant names. Two active rigs or two nodes with that name, a malformed rig-list entry, a failed or timed-out read, and any other `whoami` error are refused with the existing `grant_authority_unavailable`; a missing seat or a rig that exists only archived reads `seat_unavailable`. Nothing is created, launched or bound, and the grant, instance and revocation checks, customer scope and `principal:v1` are unchanged. The shared rig-name resolver gains an opt-in strict mode that only this check uses.
5 files, +275 -7, of which 227 are test lines, including a real-daemon test with a created, never-launched caller.
`rig_team`, `rig roster show` and `rig roster find` now say when a hosted member last called the team's MCP server, and the outcome of its latest ring. When there's no evidence, the answer is unknown, never idle. A roster row for a member on another host reads unknown instead of borrowing this host's record.
After a caller's grant authenticates, the MCP HTTP server writes the seat, the grant's name and the time to `mcp-http-presence.json`: mode 0600, beside the token file and never in it, at most once a minute per seat. An unauthenticated or failed request writes nothing, and a failed write never fails the request. The latest ring is read from the member's recent rows. Callers using Cloud grants have no presence.
The hosted-members guide now says a webhook receiver should tolerate a repeated `qitem_id`.
10 files, +207 -13, of which 96 are test lines.
A Pi seat whose saved history cursor isn't one Pi holds, such as an interface event id saved by OpenRig 0.6.11 to 0.7.1, now recovers it at the next start. When Pi refuses the history catch-up, the runner prints that the history catch-up didn't run, keeping that apart from whether the seat resumed, and re-reads the cursor from the tail with the same read-only refresh it already sends after every turn. Nothing is reset or replayed. It no longer prints a runner error and re-sends the same cursor at every restart until a turn completes.
On Pi, an event's own id no longer becomes the saved cursor; an explicit history entry id still does. OMP is unchanged.
Reported by smota in Discussion #2117.
2 files, +48 -2, of which 37 are test lines.
When an agent parks its own ask on the person it asked, OpenRig's Slack side now takes the agent from the row's source. The park's post stays in the ask's thread instead of opening a new top-level post, and a colleague's reply in that thread reaches the agent who asked, attributed to the colleague. The ask stays parked on the person until they answer, and their own answer still resolves it.
A Slack message routed to a person is recorded for Steering and the TUI when it arrives and is no longer posted back to Slack as a new ask carrying OpenRig's routing lines and the shared channel's policy text. If an agent later parks that message on a person, that park is a new ask and is posted as usual.
Threads whose root was recorded under the person before this change keep routing replies to that person.
4 files, +172 -4, of which 153 are test lines.
Main's openrig-core plugin manifests move from 0.1.17 to 0.1.21, and the packaged-plugin baseline fixture moves from 0.7.1 to the published 0.7.2 archive (plugin 0.1.19). The plugin's payload on main has changed since both published 0.7.2 and the 0.7.3 cut, so a build of main now passes the stock packaged-plugin version check instead of failing it.
3 files, +6 -6, metadata only.
With Pi cost reporting turned on, a seat name that once ran Pi and now runs Claude Code or Codex shows its current cost in `rig usage cost` and the TUI's Cost view, not its old Pi estimate: a stored Pi session replaces the seat's entry only when it's newer than the seat's latest context sample. An OpenRouter charge lookup that is retried while unacknowledged outcomes are being re-posted now finishes once the re-post ends, instead of staying pending until the seat's next turn. Both live behind the off-by-default `usage.pi_cost_facts` and `usage.pi_openrouter_charge` settings.
Found by oSquashBlossomo's review in their fork's PR #53.
4 files, +55 -2, of which 34 are test lines.
A hosted member, an outside tool on the team whose roster entry declares a webhook trigger, is now rung when work lands for it. The daemon POSTs a pointer to the row (its id, destination, sender and summary, never its body) to the member's declared webhook, taking credentials only from secret refs. Credentials in the URL are sent as a Basic Authorization header, and no credential appears in any recorded result or posted finding.
An accepted ring is recorded as accepted, not as picked up, on the wake that rang. A daemon restart doesn't ring that same accepted wake again, while a new wake for the row, such as an unblock or a resolve decision, still rings, and a failed ring is retried. A member whose entry declares no trigger stays unroutable, so its person is told, as before. `rig queue create --verify` says the trigger accepted the ring, not that the member picked it up.
14 files, +608 -75, of which 316 are test lines.
Before writing a preset, a community-harness author now checks that the harness answers one prompt on its own, outside OpenRig, using the folder the launcher gives the seat rather than the harness's default one. A provider, model or login problem then shows up in the harness itself instead of costing repeated seat relaunches, which can't fix it. "Write your own" points back to the step.
1 file, +9 -1, text only.
An ask to a registered person's own address, such as `sarah@external`, and a row parked on it now show in NEEDS YOU in the slice, mission and rig reviews (`/api/review/*`, read by the web review pages and the TUI), and the person is no longer listed as an AGENTS row holding that work. The reviews ask the queue's own participant facts, so a registered alias such as `sarah@host` counts too.
A registered shared channel takes correspondence, not decisions: a row sent to or parked on one never appears in NEEDS YOU, whatever its tier, and the channel isn't an AGENTS row. Typed gates such as `external:qa-pass` stay out of NEEDS YOU.
3 files, +110 -17, of which 47 are test lines.
A fresh `rig handover --source fresh` on Claude no longer reports the restore packet delivered just because paste and Enter returned. The daemon reads Claude's composer after Enter. A submitted packet is reported delivered. One still staged gets a single Enter retry on complete staged-text evidence, and if it stays staged the handover fails with `context_delivery_failed` and the successor is unwound. When the composer can't be observed or the result is unclear, the handover succeeds with `restorePacket: { status: "unconfirmed", reason }`.
`rig seat handover` and `rig handover` say the packet was sent but its submission is unconfirmed, give the reason, and say to check the successor before sending the context again. Rebuild handovers and the rest of #1544 are unchanged.
7 files, +213 -16, of which 101 are test lines.
An artifact whose frontmatter opens with `---` but never closes is now treated as malformed frontmatter and excluded from artifact-ready wakes, so an unfinished file no longer wakes an unfiltered artifact pool early. A file that uses a leading `---` line as a horizontal rule is excluded the same way; the option's documentation says so. An edge target with an incomplete header still counts as existing, so a missing-downstream-artifact repair wake isn't manufactured for it.
3 files, +59 -2, of which 52 are test lines.
A delayed older Claude hook no longer replaces the current state in send readiness, attention clearing, review telemetry or the wake scheduler. An old `Stop` arriving after a current limit can no longer permit a wait-for-idle send, and an old `Stop` arriving after a permission prompt can no longer settle that prompt's open wake alert. These readers now use the activity store's existing source order for Claude, as the limit readers do since #1908. Other runtimes keep arrival order.
9 files, +141 -9, of which 113 are test lines.
An agent's reviewed history can now be resumed by node ID. After `rig seat history` returns a history for an exact node, a client asks the startup review for it: the review names the node, the chosen snapshot or history, the planned settings sources and an opaque revision. Apply then rechecks all of them and resumes exactly that conversation through the existing restore engine. If anything changed, it asks for a new review instead of resuming something else, and it never starts a fresh conversation in its place.
The result reports what actually happened. A resume that ran but couldn't be confirmed reports `resume_unconfirmed`, and a permission that can't be resolved reports `permission_unavailable`. Default startup and restore are unchanged for existing callers.
This is the last of #1837's three stages, after #2051 and #2100. 18 files, +1056 -51, of which 621 are test lines.
OpenRig's MCP server now also serves clients on the 2026-07-28 MCP protocol: they don't send `initialize`, carry the version on each request, and may open with `server/discover`. Those clients reach the same tools and durable work loop over stdio and `rig mcp serve --http`. Clients on the 2025 protocol keep working on the same endpoint and process.
It moves from the 1.x SDK and its Express stack to `@modelcontextprotocol/server` 2.4.0, `core` 2.4.0 and `node` 2.1.2 (Apache-2.0). The 1.x SDK will not get the 2026-07-28 protocol. Token-file grants, Cloud grants and customer scope, principal attribution and the loopback Host check stay in front, unchanged.
Behaviour changes every client sees in `tools/list`: each tool's input schema is now advertised as JSON Schema draft 2020-12 (was draft-07), and the `execution.taskSupport: forbidden` field is no longer sent.
7 files, +870 -346: the 36 tool registrations move mechanically to `registerTool`, tests +151, and the lockfile accounts for most of the rest.
When work waits for an address nothing can wake, such as a hosted member that lives outside a terminal or a mistyped address, the stuck sweep now tells a person instead of the address that can't hear it. It tries the operator seat when that's a person, else the row's creator, else the operator agent, and skips any that is unset, the same address, missing or itself unreachable. The finding says why, and that a hosted member sees the work only when it next checks.
A seat that has launched since its wake keeps its own finding. The finding still closes when the work is claimed. Nothing new is refused.
3 files, +91 -1, of which 64 are test lines.
The Claude status-line reference now names the other-login case for rate-limit attribution. A helper Claude started from a seat's shell on a different login is attributed to that seat, like a same-login helper, so its rate limit can hold pending handoffs to every seat in that seat's Claude pool for one retry interval, even when the seat's own login still has room.
Docs only, one file, +5 -2.
When a seat parks a row on a person's own address, such as the `<id>@external` address their inbound Slack rows carry, only a Slack reply written after the current question posted now resolves it. That's the reply-ordering rule legacy human seats (`human@kernel`) already follow. Before this, a registered person's address skipped the check, so an early follow-up could answer a question asked later.
The person's earlier messages still reach the agent as context; only the automatic resume waits for a reply after the question. The resolver uses the queue's existing person-address check, so a registered alias counts too.
2 files, +62 -3, of which +3 -2 is product code.
CI now runs the Linux-only seat-environment test in its own job on pull requests and manual dispatch. The macOS package matrix skips it because it reads `/proc/<pid>/environ`, which left #2041's launch paths untested there. The test also checks that new and existing tmux servers give seats the runtime directory, the disabled session-bus default, and tmux's own terminal identity rather than the daemon parent's.
The job has read-only contents permission and no secrets, and takes about 90 seconds. The required checks are unchanged.
3 files, +61 -2.
A Claude seat that hits a rate limit now reads as at its limit, from Claude Code's typed `StopFailure` hook with `error: rate_limit`. OpenRig never reads the error text. The wake ladder then holds the seat's handoffs for its existing retry interval instead of typing into a stopped seat. Limit events are ordered by when the hook fired, so an older event arriving late doesn't clear or reassert a limit. When seats sharing a Claude home report conflicting limit windows, only the failing seat is held.
Known limit: a helper Claude started from the seat's shell inherits the seat's environment, so its rate-limit hook is attributed to the seat. On the same login that's right, since the quota is the account's. A helper on a different login can hold the seat's handoffs for one retry interval. Binding the hook to Claude's own session id would remove that.
17 files, +533 -47, of which 361 are test lines. The plugin version is unchanged.
`rig queue block <id> --on sarah@external --summary "…" --evidence-ref …` now parks the row and keeps both fields when `sarah` is a registered person, as the same park on `human@kernel` already does. Before this it refused with `summary_evidence_not_persistable`. The check uses the queue's existing person-address predicate, so a registered person's address or alias counts. A bare park on a person still needs nothing new, and other transitions still reject the fields.
2 files, +26 -2.
`rig mcp serve --http` now tells the daemon which grant's caller acted, and the daemon records it beside the rows that caller writes (identity_provenance `principal:v1`, plus a nullable identity_principal column, migration 140). A token-file grant is recorded by its name. A Cloud customer is recorded by id, grant id and version. The principal is sent only to an endpoint proven to be this host's own daemon (its /healthz self id, loopback with no forwarding headers), and recorded only on direct hops. Draft-aware rows keep it.
The header carries non-ASCII names as JSON escapes, so a token-file grant named in any script keeps working. Rows written another way are unchanged.
20 files, +557 -58, of which 315 are test lines.
Carries 0.7.3's capability map, with its `capability-delta-v0.7.3` marker, and the CHANGELOG 0.7.3 section from the release branch to main, so the next release builds on what 0.7.3 teaches. Both files are byte-identical to the 0.7.3 candidate (30a4ea13), and neither had changed on main since the cut. The CHANGELOG section is the candidate's text; the exact published notes are reconciled after publication.
`docs/reference/whats-new.md` waits for the version bump after publication, because a repo test checks that it names the packaged version. Versions, install pins and the plugin manifests also wait for the tag.
2 files, +456 -9, docs only.
Docs only. These are the gaps the 0.7.3 docs check found, fixed on main:
- The Hermes preset and its example no longer say a stopped seat's gateway exits once idle; that hasn't been checked, so they say to check with `ps`.
- The Codex permission envelope in `rig-spec.md` says OpenRig's tests prove the launch flags, not that Codex enforces them, and that an OpenRig older than 0.7.3 launches the seat without the envelope.
- Stopping a community seat says whether the harness's own processes survive isn't exercised, so check them after `rig down`.
- `getting-started.md` and `help.md` say `rig setup` and `rig doctor` run the tmux capture check (`tmux_capture`).
- `docs/as-built/cli-reference.md` lists `rig seat history`.
6 files, +18 -9.
`rig down`, `rig whoami`, `rig ask` and `rig health`, run in a tmux pane without OPENRIG_SESSION_NAME, no longer print "invalid option: @rigged_node_id" and "@rigged_session_name" lines. Those were tmux's own error output from the identity lookup asking for an unset option; the lookup already fell through correctly, and the error still reaches the code that handles it.
The OpenClaw native run found it. It has been there since rig down's own-rig check (#1838, in 0.7.2). CLI only, +21 -1, with a test that runs real tmux against an empty socket directory.
The TUI no longer shows "1 read(s) failed" on a seat whose runtime doesn't report effort, such as a community harness. The daemon's 409 effort_unsupported_runtime is an answer, and the panel already showed it correctly; the page reader just counted it as a failed read too. A real server error still counts.
The OpenClaw native run found it. It has been there since the effort read was added (#1529, in 0.7.2). TUI only, +18 -2, with one test for both the refused and the failing case.
A tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, can now be seen as a member of a rig's team. A roster entry can say it's hosted (`"hosted": true`), and `rig roster show` and `find` then report the member as hosted rather than unmatched. `rig_team` lists the rig's hosted members with their authored capabilities and limits, no pane, and activity `unknown`: OpenRig doesn't observe such a member between calls, so it never reads idle. A roster folder or file that can't be read gives a generic `hostedMembersNote`, never an empty list.
The MCP server's instructions now name the loop a member uses on its rows (list, claim, then answer with a resolution or a handoff). Addressing rows to the member and working them under a grant already worked; this adds the team view and the guidance.
9 files, +258 -12. Docs: `docs/reference/hosted-members.md`.
`rig seat history <node>` lists an agent's captured startup histories for an exact node ID and validates a chosen one, in text or JSON. Each entry shows its resume type and saved token state, never the token. Eligible means the engine can attempt that resume, not that the native conversation still exists. A reference that is invalid or no longer current is refused rather than replaced with another history. Codex and Claude are covered first.
It reuses startup's current snapshot lookup, its restore-preview classification and each adapter's resume check, and doesn't launch anything or call a native loader. Existing startup and default restore behaviour are unchanged for valid inputs; malformed imported metadata now reads as unverified instead of crashing discovery.
This is the shared-selector stage of #1837. Applying a chosen history is a separate later change.
10 files, +637 -29, of which 394 are test lines.
`rig mcp serve --http --cloud-grants <url>` takes its grants from OpenRig Cloud instead of the local token file, for a Core instance whose callers are Cloud customers. Each request's customer key is checked against Cloud for this daemon's own instance id. Any answer but active is refused (401, or 503 when Cloud is unavailable), with no fallback to the token file. A customer grant reaches only the queue and artifact tools it names, sees only its own items and the team's questions to it, and every row it receives drops the parties outside its scope. Self-hosted token-file grants are unchanged.
The caller-seat create refusal (78f426ba) is reverted in this merge while it waits on a product decision; it comes back by reverting 0f93cfed. Named limits: the caller-seat read rule rests on Cloud's one-customer-per-rig provisioning contract, not general tenant isolation, and matching the instance is a configuration binding, not cryptographic authentication.
5 files, +765 -50, of which 374 are test lines.
`rig` or `rig tui` started in a terminal that reports 0 rows or columns, such as a detached, headless or just-resizing pane, no longer crashes on its first frame. It skips that frame and draws on the next resize. A size the terminal doesn't report keeps the existing defaults.
The crash has been there since 0.7.2. TUI only, +13 -3, with a stub test at 0 rows and 0 columns.
The Hermes example config and the community-harness presets guide now say that Hermes runs tool commands in its gateway, one per Hermes home, so one per seat with the example launcher. They said "one gateway per machine", which a two-seat run disproved: each seat had its own gateway, home and socket, and stopping one seat left the other running.
Docs only, two files, +5 -2. Hermes resume remains undeclared.
An OpenClaw seat whose local turn was interrupted, and which now shows OpenClaw's "Session … changed while starting work. Retry." frame, reads as needing attention on nodes, `rig ps` and the TUI, with "OpenClaw turn not recovered; inspect the pane before retrying". A send to it still goes through, as today, with an advisory that OpenClaw refused its last local turn and that `rig capture <seat>` shows it.
The recognizer reads only OpenClaw's own footer above the editor, so pasting the error into the input box doesn't produce the diagnosis. It observes; it doesn't repair native records, replay the request or start a fresh conversation. The presets guide recommends Gateway mode for unattended seats. 0.7.3 slice 06, OpenClaw (a).
When a wrapped Codex seat's pane holds a conversation other than the one its verified process owned, the generation-record route now says so: "The Codex conversation in the pane for '<seat>' isn't the one its verified process owned, so no generation record is served." `rig walk` prints this verbatim. Before, the case reused "The occupant binding changed while resolving", which was untrue for it.
Same `record_identity_unverified` code and 409 status; the binding re-checks are unchanged. Follows #2084 (Aschenbath).
A workflow whose done or handoff advances past a declared human park that nobody answered now says so in the existing advisory output and in the same transition note, on both the serial and dependency-graph paths. Nothing is refused: the step still proceeds.
The shipped `messaging-the-human` skill (all three copies) and the resolve nudge say that a response is not consent or authority, and show `--exit waiting --blocked-on <person>` for waiting on a person. Refs #1710.
The community-harness contract in `docs/reference/community-harness-presets.md` now has a test behind each claim with no native proof yet: delivery, refusing a shell or a numbered choice, an unrecognized prompt reading as unknown, no approval relay and no cancel verb, startup files not placed, the preset's own model, named and reported resume never starting a fresh conversation, stop and down leaving a sibling session untouched, and a single launch after an unconfirmed identity.
The survival of processes a harness starts outside its pane is not exercised. Test-only: six daemon test files, +286 -2.
A verified Codex seat launched under a shell or launcher gets its generation record again, so `rig walk` can check consumption instead of always reporting it unverified. The route compares the live pane root with `observedPanePid ?? observedPid`, the rule the current-generation helper already uses for Claude.
For a verdict that records both PIDs, the thread selected under that root must also be the thread resolved for the verified native process. A different Codex conversation started beneath a surviving wrapper is refused with the existing `record_identity_unverified` 409. Legacy verdicts that recorded one PID keep their previous check.
The diagnosis is @fsafey's, in #1799.
`rig export` now writes each pod's saved startup files under that pod, keeping their order, delivery hints, required flags, orientation and `applies_on` selections. Previously those declarations were dropped, so a re-imported rig could start members without their pod guidance. The CLI checks required rig and pod files against the output folder and warns once per missing one.
Files are referenced, not copied: when exporting to another folder, supply them there or import with the original `--rig-root`. Rigs with no saved pod file list keep their existing export behavior. Pod startup actions and member startup blocks remain separate work (#1631 stays open for them).
Daemon tests that use a mocked terminal no longer wait on real 200 ms startup sleeps. The existing `sleep: async () => {}` hook is injected at 14 `StartupOrchestrator` construction sites in 11 test files. No production code, assertion, timeout or concurrency setting changes. In the author's measurement the affected files' wall time fell by 84%.
Test-only, +15 -14.
`rig slack enable` treats the asks already open as seen, so none of them post: enabling never replays a backlog. `rig slack post-open-asks` now brings those asks into Slack through ordinary delivery, at the level each was asked at (quiet hours, digest and the posting floor apply), and says what became of each one: posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer active, superseded, recipient changed, state unavailable, transport failed or uncertain. Running it again reuses each ask's one decision, so a confirmed post isn't repeated; one whose earlier attempt Slack couldn't confirm may post once more, marked as a possible repeat.
State that can't be read is never taken as absent. An unreadable dispatch buffer, seen store or attempt record reads as state unavailable or uncertain, and an attempted re-shown ask whose Slack outcome is unconfirmed, or whose attempt log can't be read, stays in the buffer for its next replay instead of being recorded as not posted. Named limits are in the PR body.
The need came from rakoort's report in #1679.
A sender who wants a busy Codex seat to do separate work is now told to create a queue row (`rig queue create --destination <seat>`) rather than to wait for idle. A saved row keeps the work apart from the short wake typed into the seat, and its state, not the wake, says whether the seat claimed or finished it.
`--wait-for-idle <seconds>` is described as it behaves: if the seat is still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps nothing to send later.
Text only, in the send advisory, `docs/reference/seat-delivery.md`, `rig send --help` and the MCP send tool: +13 -4.
A spec author can now tell from the docs what a restored seat receives. An exact resume (`rig up <rig> --existing` reopening the saved conversation) types no startup actions and delivers no startup files, `restore` ones included, apart from putting back a missing managed guidance block. `restore` items apply when a seat comes back without its earlier conversation: a resume that falls back to a fresh one, or a retried start.
The startup guide also says not to rename a managed Claude seat. OpenRig starts every Claude seat with `--name <session>`, and context usage, managed compaction and input-box recognition read the seat by that name, so a startup `/rename` breaks them until the next resume.
Docs only: `agent-startup-guide.md` and `rig-spec.md`, +12 -5.
`rig capture <seat> --readable` (or `--size WxH`) reads a seat whose pane is too small to read. It resizes the seat's window for the read and gives it back afterwards, through the same capture path.
The resize joins #2065's window pin record, so a web terminal opening during the read keeps its own pin, and the size is put back only by the last live holder. A pin left behind by a crash is repaired by the next capture. Nothing resizes without a written record, and one 8 s budget per request bounds a `--rig` or `--pod` fan-out (the CLI waits 15 s); after it runs out, seats read as they are, with a reason.
The cost: the seat's TUI redraws twice, and a viewer sees the resize.
Someone running the TUI inside cmux who uses a rig's `term ▸` link or **Open terminals** now gets the view as a new cmux workspace, the same result as `rig terminal open <view> --provider cmux`. Before, the TUI always ran `--window`, which never reaches cmux.
Detection needs `CMUX_SOCKET_PATH` and `CMUX_SURFACE_ID`, which the daemon's own environment scrubs, so a shared TUI in the daemon's tmux isn't read as cmux. Herdr or Rex inside cmux keep their view. A cmux preview sends no width, so the kernel view's plan matches its open, and a refused cmux socket names cmux's Automation socket mode. Outside cmux, nothing changes. Refs #1069.
A person whose tmux crashes on `capture-pane -p` now learns it from `rig doctor` (a `tmux_capture` failure) and `rig setup` (its tmux step), with tmux's own error, why it matters and the fix. Before, every seat failed at launch with "server exited unexpectedly".
The check runs the getting-started guide's existing command on a throwaway socket with no config, so a person's own sessions are never touched. Every command is bounded, and the throwaway server always gets a cleanup attempt. A server that won't start reads as not checked, not as a failure. There's no minimum tmux version. Refs #980.
The agent startup guide gains one table: what a seat is saved to use at its next launch, what the running seat actually has, and the action that brings a saved change to it, for model, reasoning effort, permissions, working directory, startup files and skills, and tools. Without live evidence it says unknown or not verified, never the saved value.
The quickstart gains a reading order for newcomers: how OpenRig works, first use, `rig <command> --help`, then the CLI reference.
The A2A walkthrough now fixes the five problems a cold read found. Start the sample peer yourself in a separate terminal (its address exists only while it runs); fetch the pinned sample commit; when `A2A_TEXT` is unset, the row body becomes the peer's message verbatim; the example asks for an exact reply; and a worker is woken with `rig send`, or by removing `--no-nudge` to get the normal queue wake.
No new interoperability claim is made.
The Slack setup guide now says that messages routed to the same continuing agent conversation can use that conversation's earlier context, whoever sent them. A queue row records who sent a message and which seat owns it; it doesn't start a separate agent conversation.
If people need separate conversations, route them to separate seats (`--inbound` names the seat a channel's new messages reach). Separate channels help only when their routing also keeps the conversations separate. A conversation shared on purpose is fine.
With a shared channel registered and no person, OpenRig records an act on the channel's ask as the person at the terminal (`human@host`). The Feed now serves that same actor on a channel's ask or a row parked on one, so the TUI's label, its success notice and the record agree.
A request that names a shared channel as the actor, without the terminal's primary-user authority, is now refused for drop, hold, route, handoff and annotate, as it already was for answer, approve, deny and resolve. Nothing is recorded. With the authority, a channel claim is replaced by the primary user for every verb, as before. Agents act as their own seat, so their routing is unchanged.
Left as is: if `participants.yaml` can't be read, no shared channel is known, so only asks and replies from before the file broke are exposed. The remedy is repairing the file.
The hermetic test helper no longer reports cleanup complete while its private tmux server's pane children can still write to, or recreate, the scratch HOME.
It records the server's panes and their existing descendants before the signal, waits a bounded 2 s without signalling any other process, then removes the root. An unknown process identity or a timeout keeps the fixture, and a failed cleanup stays failed on later calls. The new tests keep their own startup under teardown, and a repeated hangup no longer ends the fixture child before its final write. Test layer only; no production code changes.
`rig down` now names every community-harness seat whose pane it stopped: "Pane stopped: <seats>. A community harness's own processes may keep running after its pane; see its preset's limits." It also reports them in a new optional `paneOnly` JSON field. Before, a Hermes gateway that outlived its pane was silent.
OpenRig still stops only the pane, and only seats launched for a community harness are named, not a built-in runtime or a free-text namesake. Part of 0.7.3's Hermes work (slice 07).
This reverts #1345, the native Claude version-label witness, on main. Every release since 0.7.0 has shipped with it reverted, so main now matches what ships, and a release cut no longer needs its own hand-made inverse of it.
All nine files are patch-identical to the inverse in published 0.7.2. The two conflicts are resolved to keep every later change to claude-code-adapter.ts and startup.ts. Nobody running a published release loses anything. Re-enabling it later is a revert of this revert, once its native qualification (#86) answers.
The community-harness presets guide now says what a contributor's harness gets from OpenRig, capability by capability: launch, delivery, identity, resume, activity, attention, approval, cancel and transport. Each is marked supported, when declared, partly or no, and each says what the person sees instead. Anything unsupported reads as not known, never idle or ready.
It adds a five-step walk-through for writing your own preset over the Cursor, OpenClaw and Hermes examples, and the promotion test. Promotion to a built-in runtime is decided by the OpenRig maintainers, on evidence from OpenRig's own testing. Passing the checks isn't automatic promotion or a promise of upstream support.
After `rig slack channel-map set` or `remove`, the CLI now says a running connector picks up the change at a daemon restart (`rig daemon stop`, then `rig daemon start`). It also says not to use `rig slack disable` then `rig slack enable` for it, because enabling marks alerts still waiting to be posted as already seen. The old hint offered both as equal choices.
This matches the Slack setup guide and the known-issue line. Live reload is not part of this change.
Eight `rig queue` verbs take the actor from the seat's own identity (`OPENRIG_SESSION_NAME`) and ignore their deprecated `--actor`, `--from`, `--destination` or `--sender` flag. When `OPENRIG_SESSION_NAME` was unset, they said "--<flag> is required", which sent an agent to a flag that does nothing. They now say `OPENRIG_SESSION_NAME` is not set, give an example of setting it, and say the flag is ignored.
The two flags that are honoured, `create --source` and `whoami --session`, keep their message. How an actor is identified doesn't change.
## What a user gets
Applying a pod spec, launching a materialized member, or retrying a failed first start can still block the daemon while resolving profiles, even though preflight already uses a worker. These paths now await the existing profile resolver worker, so a slow skill scan or catalog read can leave the request thread available.
An apply reuses one lazy worker across members, without a scan cache: a later member still reads changes made by an earlier member's startup. Standalone launches and retries use a short-lived worker. Refs #1653, building on the preflight work in #1759 and #1873.
Worker rejections become member configuration failures. When every member fails, apply follows its existing service rollback and generation restoration. A materialized member returns a failed result; first-start retry refuses before changing retained seat/session state.
## How you verified it
Source: `1adc689a5797e23e4a4b0447c3ca9000e069b893`, based on `4e4cba36f9f454e927e9bd508f299bd09cc68886`.
- Three launch-path regressions failed against the original instantiator at the health/FIFO assertion. The test requires the FIFO to remain blocked when HTTP health responds, then checks the selected skill reaches projection. It also checks fresh reads and dependency order between members.
- Four worker-fault cases failed before the rejection handling: apply skipped service rollback, while materialized launch and retry threw. They now verify rollback, restoration of the replaced generation, and preservation of retained seat/session state. The complete worker file passes all 16 tests.
- Native Linux verification passed `npm run lint` and all 91 tests in `profile-resolution-worker.test.ts`, `spec-library-starters.test.ts`, `claude-activity-hook-delivery.test.ts`, and `rig-added-seat-culture.test.ts` (four files, no failures or skips). The tested staged tree matches this commit: `67bab58ce12b7019a300dadbe81b7dc5cf3f8d74`.
- The committed native Linux checkout passed `npm run build`, `npm run test:repo`, and the host-mode `up-relaunch-restores-a-two-seat-team.yaml` scenario (1/1). The scenario starts two stub members, stops them, and starts them again with the expected recorded membership and state.
- [Final PR CI](https://github.com/mvschwarz/openrig/actions/runs/40276062989) is still running. Its results will be added when the complete package suites finish.
- The staged portability report found no matching machine-specific values; the PR portability job also passed.
An initial full daemon run from a Windows-mounted WSL checkout was stopped after a file-mode mismatch and two timeout failures. The mode mismatch and preflight timeout reproduced with the base production code; the culture test passed individually on the base and initial async implementation. The first host-scenario attempt from that checkout also exceeded daemon startup readiness before reaching any scenario assertion; it passed after moving the same source to native Linux. The native Linux tests above use the existing assertions and deadlines. The interrupted run is not counted as a complete suite pass.
## Anything you were unsure about
The responsiveness regression uses the real instantiator and SQLite with a simulated runtime. The scenario DSL has no concurrent health assertion or filesystem gate, so the existing worker-test FIFO fixture exercises the blocking condition directly; the separate two-seat scenario checks CLI/daemon/tmux lifecycle plumbing. Native Claude Code/Codex behavior and other synchronous startup work are not established by these tests.
Worker creation and copying the resolution context add some overhead. The existing deleted-process-cwd fallback remains synchronous. No whole-daemon latency benchmark is claimed.
- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Improvements**
* Health requests remain responsive while profiles are being resolved during member launches, materialized launches, and first-start retries.
* Profile changes made during earlier members’ startup are picked up when resolving later members.
* **Bug Fixes**
* Profile resolution failures are reported through the existing launch and retry failure handling, without launching affected members or leaving altered rig or session state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The CLI reference now lists the flags these commands actually take. `rig provider precheck` and `rig provider switch` gain `--token-file` and `--stored-login`, and `rig seat set-permissions` gains `--rules`, `--codex-permissions` and `--codex-profile`. None is marked required any more, because each command takes one of several.
The reference was regenerated at main by the docs-desk generator, not hand-edited. It changed exactly these three rows and the header's source commit and counts. `--token-file`, `--stored-login` and `--rules` shipped in 0.7.2; `--codex-permissions` and `--codex-profile` are main only.
Main's capability map now teaches 0.7.2, the same text the published v0.7.2 map carries. Before, main still read v0.7.1, because 0.7.2's map was written on the release line only.
The 0.7.2 map commit is ported unchanged. Two lines that main's default-person change made wrong are corrected: the last escalation step goes to the default person, or the only one registered, and reaches nobody when several are registered with no default. A workflow's human step points to `rig gateway default <id>`, with `workspace.operator_seat_name` still read first.
After someone watches a seat in the web terminal and closes it, the seat's window gets its normal size back in Herdr, `tmux attach` or the operator's own terminal. Before, it stayed at the browser's size, so a native client saw narrow text with a dotted background around it.
The broker pins and gives back the window by its id (`@N`). It records the prior `window-size` and the brokers holding the pin on the window, in `@openrig-window-size-prior`. The last live holder gives the window back. A pin whose holders are gone, after a daemon restart or crash, is given back by the next broker, and a window that no longer lists this broker, such as one a restarted tmux server reused, is left alone. Every way the broker lets go gives the sizing back before the registry drops it. `rig capture`'s small-pane hint now also says how to unset the window's own size.
Not covered: a browser that closes while the daemon is down (the window is repaired on its next visit, or by hand), and two brokers updating the record at the same moment.
On a fresh install with no one registered, the notice digest's skip reason now says "no person is registered" instead of "the human registry can't be read".
The registry loader reports "no fragments and nothing projected" as a refusal marked `noHumans`. The flush now checks that before treating the result as unreadable. A registry that genuinely can't be read is still reported as such, and dispatch is unchanged. The test uses a real fresh home through the read-only loader.
Follows #2063 (thanks to @Coder8124 for the skip reasons). Part of #800.
The shared `--workspace` help now says what the commands do since #1093: `slice create` and `approve` refuse when the named workspace's tree is missing, and `mission create` refuses a path that isn't an existing directory and otherwise creates the missions tree inside it. Read commands keep the fallback to the configured root.
Text only; no behaviour change.
The person at the terminal, the primary user, can look at a colleague's queue in the TUI and act on what that colleague was asked. The record keeps both identities: who acted and who was asked. Viewing someone's queue is never acting as them.
- `whose <person|all|default>` chooses whose asks the Feed shows, and its first line names whose queue it is and who acts. With a chosen default person the TUI opens on that person's Feed; otherwise it opens on the topology, as before.
- Acts on someone else's ask (approve, answer, or resolve a row parked on them) are recorded as the primary user, with the person asked in the audit notes and the transition ("by mike@external, the primary user at the terminal, on sarah@external's ask").
- A registered person without the primary user's authority still can't settle another person's ask, pending or parked; that's refused with answer_not_recorded and nothing changes.
- The authority is honored only on a Mission Control request with no seat identity header, which is how the TUI calls the daemon. Seats and the CLI keep their own identity. This is the existing trusted-network distinction, not authentication.
- A registered shared channel never acts: with no one registered, a terminal act on a channel's ask is recorded as human@host with the operator's words; a channel's own answer, approve, deny or resolve is refused before any change; and a customer's message in the thread is never taken as an existing answer.
- Otherwise, with no one registered, the Feed and its acts are as before.
The primary user is #1992's default person, or the only registered person; with several people and none chosen, acts record human@host. `GET /api/attention?view=` filters the Feed's person items and returns a `perspective` block, and delivered updates follow the same view.
On main after #1992, #2061 and #1998, resolveParked keeps the typed external:, fold: and auth: gates and folds #2061's asked-person check into asActor, which refuses another registered person the same way and still lets the primary user act. A typed gate has no asked person, so a primary-user resolve keeps the gate's note.
Guidance is in docs/reference/gateway-participants.md ("In the TUI: whose queue, and who acts"), with upgrade and rollback lines. No new schema, dependency, setting or route.
When the notice digest sends nothing, the watchdog's skip reason now says why instead of "nothing to flush":
- the person registry can't be read;
- no person is registered;
- every item is held for quiet hours;
- notices wait because several people are registered and none is the default;
- dispatch failed before anything was enqueued.
`runDeliveryDigestFlush` returns an optional `idleReason` only when it dispatched nothing, and the watchdog policy uses it as the skip reason. Dispatch itself is unchanged. A real-SQLite test covers each reason.
Part of #800.
`rig scope --workspace <proj> mission create <name>` and `slice create` no longer fall back to the global `workspace.slices_root` when the named workspace can't be used. They stop with an error and write nothing.
- `mission create` with a workspace that doesn't exist, or a workspace path that is a file, is refused, so a mistyped `--workspace` (or `OPENRIG_WORK_ROOT`) doesn't start a new tree there. The configured root isn't used.
- `slice create` refuses the fallback when the named workspace has no missions tree, because the mission it adds to must be in the tree you named.
- Unchanged from #1430: inside an existing workspace, a project's first mission goes to its declared `missions.root`, else `<proj>/missions/`, created if missing, with the same advisories, and reads resolve the declared root the same way.
#1430's tests that expected `mission create` to create a missing named workspace now expect the refusal, as does the workspace-is-a-file case; a new case covers `slice create`.
Refs #70.
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat without registering each of them.
- In: an unregistered Slack sender is admitted only in a registered shared channel's own Slack channel, as that channel participant (`acme-customers@external`), including with no registered person at all. The seat's row carries a labelled block, last after the message: the channel, its organization and groups, who actually wrote (marked as not a registered person), and the channel's effective policy. It's tagged `participant:<id>`, `shared-channel` and `slack-sender:<user>`. A registered person writing there stays themselves.
- Correspondence by construction: a message admitted as a channel never becomes an answer, never closes or resumes an ask (even one addressed to the channel), and a click there records nothing.
- Back: a registered shared channel is a valid queue destination. A row to it posts as a notice, whatever its intent. An update with `--reply-to` lands in the customer's thread, and so does a later one; posts to a channel mention nobody. An ask to a channel is accepted with an advisory that nothing there will answer or close it, and its questions show as a list without buttons.
One participant per vetted channel, from #1992's participant file; no customer is registered and no second store is added. Admission fails closed: an unreadable participant file admits no channel senders, and a broken person registry still refuses everyone. Policy is readable context in the row; nothing enforces it.
Tests cover admission, channel-only setups, never settling anything, and the way back through the real Slack wire with a fake transport.
A registered host ID containing dots, such as `mac.local`, can now be enabled with `rig config set feed.subscriptions.mac.local.enabled true`. The host registry already accepted such IDs, but both subscription parsers rejected the key and the daemon's subscription reader ignored it.
Both parsers and the reader now accept dots in the host ID. Storage already treats the captured ID as one literal JSON property, so there's no migration, escaping scheme or registry change. Reserved toggles, boolean-only values and the other unknown-setting checks are unchanged.
Refs #2057; thanks to @AdamWhitehurst for the report and the config-side reproduction.
Tests cover both writers, persistence as one literal host key, read and reset, parser parity, and the real attention aggregator with mocked HTTP, including disabled-host filtering. No new refusal or error code.
* Prepare registered-person park resolution for remote verification
* test(queue): expect conflict when a park is already resolved
* test(queue): preserve asked-person semantics for terminal resolve
---------
Co-authored-by: dev-driver <dev-driver@openrig-build>
* feat(gateway): several people, shared channels, organizations, groups, readable policy and a default person
The gateway can now register several people and shared-channel participants
(one vetted Slack channel is one participant at <id>@external), group them into
organizations and groups, give each readable policy text, and name a default
person.
Storage keeps rollback safe. Person fragments keep the closed key set a v0.7.0
daemon reads, so an older daemon still loads every person. Everything new lives
in gateway/participants.yaml, which v0.7.0 and v0.7.1 never read. A broken side
file never disables the person registry; a person removed by hand leaves a
warning, not a broken file.
Policy is text agents read, shown organization first, then each group, then the
participant's own, each labelled with its source. Nothing enforces it or derives
it from names.
The default person serves needs that name nobody: escalations, the notice
digest, Mission Control's my-queue, the workflow fallback and pairing approval.
It's the chosen person, else the only one, else none with the reason, never the
first file by id. workspace.operator_seat_name keeps its existing meaning.
Verbs: `human add` accepts several people and takes --organization, --group,
--policy/--policy-file and --default; `human show` adds the placement; `human
set` takes organization, groups and policy; `human remove` also clears the
person's placement. New: `channel add|remove`, `org set`, `group set`,
`participants` and `default`.
A second registered person's Slack reply, typed or clicked, never answers,
closes or resumes another person's pending or parked ask. Main already guarded
this at two independent places; the new test pins both.
docs/reference/gateway-participants.md covers the commands, the default, upgrade
(nothing to do) and what a rollback to 0.7.1 or 0.7.0 changes. The
messaging-the-human skill points agents at `rig gateway participants`.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(gateway): a person can't take a shared channel's id; upgrade note for several people
People and shared channels share one address space (<id>@external). Adding a
channel already refused a person's id; adding a person now refuses a shared
channel's id too, in the registry's own add path and before any write, so the
participant file, its default and the channel stay intact. Before, the add
succeeded and the participant file stopped reading: the chosen default, every
placement and policy were lost, and every repair refused.
`human remove` now deletes only a person's entry from the participant file, and
`channel remove` only a channel's, so neither can remove the other kind.
The upgrade note now covers installs that already had several people: choose a
default with `rig gateway default <id>`; until then escalations report
undeliverable:no-default-person and the notice digest waits.
The ordering tests now expect the list sorted by id, people and channels
together, and pick the person by id.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: review50-r3 <review50-r3@openrig-build>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Carry the corrected published 0.7.2 notes into the changelog on main, preserving contributor credits and verification limits. Only documentation changes; no tag, archive, npm or Release edit.
Clarify when Herdr may show a waiting seat as idle: a wrapped Claude footer or a visible inner tmux status line. Keep OpenRig's view and Feed as the route to find waiting seats.
Documentation only; matching mirrored skills and generated digests. The supporting evidence uses replay and screenshot-derived frames. No native runtime fix, historical causal proof, or live settings change is claimed.
A Claude runtime-switch successor uses the existing startup input-box wait and guarded staged-text Enter retries. Shared helpers keep the startup rules in one place; Codex successors retain their existing sequence. Confirmation still controls commit, with rollback and recovery unchanged.
Independent launch-sensitive source pair clear; exact-head hosted CI passed. Builder unit RED/GREEN and broader Linux A/B are attributed. The observed native long-handover cause remains unknown and this patch has not been shown to cure that case. Bounded observation adds waiting, and full staged matching uses the existing normalization. No new setting, refusal or repaste.
Registered remote hosts stay in the topology tree without a feed subscription. Drilling into an HTTP host lists its rigs and states, with host-qualified pointers for seats. Failed reads are distinguished from empty results, and local facts/actions are not presented as belonging to the remote host.
Reported by @AdamWhitehurst. Refs #2049. Local display-label item remains separate; this does not close the whole issue.
Verification: independent source review and required hosted CI. Builder Linux tests and two hermetic daemons exercise built hydrate/render, not an interactive TUI or a real second machine. SSH hosts remain listed but remote summary forwarding is HTTP-only. Remote TABLE retains small unused local health/queue reads; GRAPH may read unused local rig graphs, attached only to the local host and not rendered as remote. No remote seat actions are added.
`rig skill loadout --seat X --apply --wait-for-idle 60000` can wait a minute instead of failing at the client's five-second default. Its request timeout is the requested idle interval plus five seconds; without a wait option it keeps the existing client default.
This is the unchanged two-file loadout delta, now based on main after #2020 merged. It uses that client's long-request path for deadlines of at least 300 seconds, sends one POST with the full original wait, and reports the daemon response unchanged. No retry, continuation, outbox, route or shared-helper change is added.
Validation: paired source review and eight required CI checks pass at40367426. Existing fake-clock real-client controls cover six-second responses for60-/600-second waits, transport selection, exactly one POST, the timeout margin and unchanged output; the actual-main restack's own patch is byte-identical. These checks do not establish a native agent reading the skill list.
New scoped tmux panes keep tmux's own `TERM_PROGRAM` and `TERM_PROGRAM_VERSION` through the launch's initial environment reset. A managed Claude seat already forwards those names, but they were erased before that step, so Claude couldn't identify tmux and request the extended keys it uses for Shift+Enter.
The reset now keeps these two fields alongside `TERM`, taking the values from the newly spawned pane rather than the daemon or its configured baseline. It applies to fresh and successor panes that use the reset. No terminal option or session environment changes. On Linux, #2036 already joins a relative `WAYLAND_DISPLAY` selected through tmux `update-environment` to the seat's runtime directory.
Refs #2041; thanks to @AdamWhitehurst for the report and the environment evidence. The obsolete integration-test assertions and first-server fallback were fixed separately in #2047. This change addresses the terminal identity loss; the Linux Wayland statement is conditional on the selected display environment.
Tests cover present, empty and absent terminal values through the composed scoped reset and managed launch, including a conflicting daemon baseline. No new refusal or error code.
Include the inbound item in external reply hints so an agent can reply in the originating thread. Existing thread resolution and update validation remain in use. Validation: author-reported focused envelope tests and TypeScript builds; all eight required hosted CI jobs passed at the admitted head. No live Slack round trip was exercised for this review.
Adds per-seat Codex permission envelopes and profile setters through the existing audited permission surface. Inherited runtime switches carry the envelope; an explicit target mode intentionally replaces it only when the switch commits. Profile checks resolve the target seat home.
Independent source pair clear; exact-head hosted CI passed. Native sandbox/approval enforcement is separate. Existing unreadable/dual stored-record handling remains; no envelope-plus-explicit-switch refusal.
The clearing helper tolerates only the intended missing-column fixture case; other initial read failures roll back the existing transaction. Focused tests cover both discrimination and switch state restoration.
Keeps managing unchanged OpenRig config tables after a Codex save moves them, and preserves other tables printed between managed markers. Array-of-tables fragments project and replace their owned entries without repeating them.
Independent Claude/Codex source review retained across patch-identical restack; required hosted CI passed. Existing limits remain: moved array entries outside markers may duplicate, entries under owned array roots inside markers are replaced, and changed moved tables remain user-owned.
The first-run reference checker now discovers shipped `SKILL.md` files on Windows. Its walker uses native `path.join`, so testing for a literal `/SKILL.md` suffix returned an empty catalog on Windows; `path.basename(p) === "SKILL.md"` handles the native separator and matches the sibling scripts' existing idiom.
One line changes. Reference parsing, skill names and validation rules stay the same. The independent source pair is clear and all eight required CI checks pass. The contributor reports a native Windows before/after run:53skills discovered,489references checked,0failures; that run was not independently repeated by the desk.
What a person gets
After this, a person's own Codex hook trust survives OpenRig's next seat launch, so Codex stops asking them to trust the same hooks again every launch. OpenRig never adds a second set of its activity hooks either.
Before:
- **Codex 0.164.0 rewrites all of `[hooks.state]` when it saves trust.** That's its "Trust all", a `ConfigEdit::SetPath` on `hooks.state`. `toml_edit` then prints the rewritten table after the last hook group, which is inside OpenRig's `# BEGIN/# END OPENRIG MANAGED ACTIVITY HOOKS` block, ahead of the END comment. The END comment was the prefix of OpenRig's first trust table, and it moves with that table.
- **OpenRig's next launch replaced everything between the markers.** That deleted the person's newly trusted entries, so the prompt came back on every launch. Turning OpenRig's hooks off deleted them the same way.
- **When a save left the markers apart from OpenRig's groups,** OpenRig appended a second block, and the relay ran twice per event.
Reported by @borfast in #17. #2028 fixed the other half of that report, the trust key's group position.
If Codex already shows OpenRig's hook twice, from a config the old code doubled, remove the extra `[[hooks.<event>]]` groups that run OpenRig's relay, once. This change stops new copies; it doesn't remove old ones.
What changed
- **OpenRig's groups are read as TOML.** The config is split at its real table headers, never at text inside a string or an array, and each header's key is decoded in any spelling, such as `[[hooks."Stop"]]`. A group is OpenRig's when it's for one of OpenRig's four events and runs nothing but OpenRig's relay. A person's own group for the same event, inside the markers or not, is theirs.
- **Inside the block:** the activity-hook block rewrite and the strip that turns hooks off now keep every section between the markers that isn't one of OpenRig's groups, whole, with the comments just above it. Codex's `[hooks.state]` tables stay.
- **Groups without markers:** when the markers are gone but groups already run OpenRig's shipped relay, those groups are OpenRig's where they sit.
- Only the `command` line of each one's own handler is pointed at the current relay, so an upgrade still works.
- No second set is appended.
- Their trust is keyed by their own positions.
- **A check before writing:** the inline writers confirm that only OpenRig's groups and the `[features].hooks` flag changed and that the file still parses. Otherwise the config is left as it is, with a line in the daemon log. A config that doesn't parse is left as it is too.
- **Unchanged:** a person's own hooks, their order, and every other table.
Tests
`codex-hook-trust.test.ts`, "#17":
- **The fixture reproduces Codex's save from the inspected writer.** It is source-derived, not a captured native save. It takes OpenRig's launch of a config with a person's own hooks, then applies the 0.164.0 write: `[hooks.state]` and every entry, sorted, with literal-quoted keys and values, printed after the last hook group, with the END comment travelling with OpenRig's first trust table. A first case checks the person's trust really is inside the markers.
- **The next launch keeps:**
- the person's trust;
- their groups and order;
- exactly one set of OpenRig's groups, with correct trust.
- It's also idempotent.
- **Turning hooks off** keeps the person's trust.
- **With the markers gone,** with the current relay or an earlier install's relay, no second set is added, and the commands point at the current relay.
**Read as TOML** (`codex-hook-trust.test.ts`, "OpenRig's groups are found by what they are, not by their text"):
- a relay command example inside a person's multi-line instructions stays exact;
- a person's group for another event that runs the relay isn't repointed;
- OpenRig's group written with a quoted event key is removed when hooks are turned off, and replaced rather than kept beside the new one;
- a kept section whose multi-line string holds a `[[hooks.Stop]]` example stays whole, and the file stays readable;
- a person's own PermissionRequest group inside the markers is kept on a launch and when hooks are turned off;
- a config that doesn't parse is left byte-identical.
With #2034's source, the four behaviour cases above them fail. With this PR's first revision, the seven new cases fail.
Execution views read transition history for blocked Q1 lanes and Q5 rows before pickup immediately returns `parked`. Skip that unused read at those two callers. Live history stays fresh, and the separate armed-wake lookup and park outputs remain unchanged. Required-read errors still propagate; failures solely in the omitted read no longer fail the view.
Validation: 140 focused owner tests and daemon typecheck passed; independent QA passed 124 existing tests plus one adverse case, and both source reviewers approved. One contained same-snapshot product comparison reduced history reads from 246 to 4 and post-cursor work from 527 to 208 ms, preserving the full response except build identity. This is a local result, not a released-version or historical-incident cure claim.
When Claude Code 2.1.309 shows its "Resume session" picker instead of resuming an exact conversation (a bare `--resume`, or a name that matches no title or several), OpenRig no longer reads the seat as resumed and ready. Before, startup input or an ordinary send could land in the picker's search, and Enter could resume the wrong conversation. 0.7.1 behaved the same.
- The native resume probe and `checkReady` read the picker as `attention_required` / `claude_resume_selection_prompt`, in tall and default panes.
- Startup reports `attention_required` at once instead of waiting out its readiness timeout; the same attention set also covers the numbered and two-choice choosers.
- A plain send at the picker returns `target_needs_input` and types nothing.
The picker is recognised by its "Resume session" heading followed directly by its framed search box. It stops counting only once Claude's framed input box or a column-0 reply is drawn after it, so an old picker above a resumed session reads as resumed, and a card title that mentions "Claude Code v…" can't dismiss a live picker. The check is skipped for Codex and Pi panes. An exact id Claude can't find still reads as `no_conversation_found`.
Tests use the real 2.1.309 picker frame. Cost against main: source +36/-1, tests +126 (including a 4-line fixture). No new error code; the existing `target_needs_input` refusal now also covers this picker.
The first seat started by rig up on a new tmux server now reads the same scoped launch options as later seats. Reuse the existing server-start helper when the initial default-shell read is unavailable, then retry that read. Existing servers and the existing fallback/warning remain.
The Linux environment test now checks the scoped values instead of expecting inherited daemon values, and a private-tmux adapter case covers the first-seat path. The existing empty-server helper keeps an empty server available, as on other startup paths.
Validation: independent paired source review, builder Linux before/after controls and focused checks, required exact-head CI8/8. The Linux /proc integration test is skipped on macOS CI; no new Linux CI job or native provider acceptance is claimed.
Long --wait-for-idle sends no longer fail at Node fetch’s 300-second header limit. Requests with a budget of at least 300 seconds use node:http/https as one request, preserving the daemon’s deadline and occupant binding. Shorter requests keep fetch. A deadline remains a timeout even during body consumption; a dropped response body stays unreadable/unknown rather than claiming the message was not sent.
The HTTP test guard is installed only in the CLI suite; shared fetch guarding and cleanup remain. No dependency, setting or daemon route. The limited transport accepts string bodies and uses direct connections without global proxy agents.
Validation: prior independent paired behavior review, independent harness-only delta review, builder real 400-second wait and focused regressions, and required exact-head CI 8/8. This does not claim a native runtime-switch test.
Large execution views collect candidate rows in one synchronous SQLite call, making health checks and unrelated requests wait for seconds. Read WAL candidates through a request-owned readonly cursor that yields between slices, and serialize complete execution builds per projector. Transactions, memory and non-WAL databases retain synchronous main-handle reads.
SQL, bindings, row order and complete results are preserved. Candidates use one committed snapshot; later history reads can observe writes committed during yields. Native steps can still block, and queued execution requests can exceed the existing client deadline.
Validation: 138 focused tests, two independent transaction/cleanup adverses, and a matched source-built full-request case confirming complete-result parity apart from build identity and health-handler progress during the cursor read. This does not establish an overall request-time speedup.
Codex resume and legacy restore now put the conversation id before the queue-state and team-workspace `--add-dir` arguments. OpenRig reads a Codex's identity from its unquoted process line, so a path containing a space there could be mistaken for the conversation id, leaving continuity unverified and later delivery refused.
The shared builder (`buildCodexResumeCore`) moves the already-resolved directory segment after the id, keeping directory order, permission choices and resolver calls. Callers without that segment produce the same commands. Process lines in the old shape are still read, so a seat already running that way is identified as before. Together with #2037, which did the same for an in-place restart, every Codex resume OpenRig builds is `resume <id> --add-dir …`.
Tests: a spaced shared-docs root and a spaced legacy team workspace each read the expected conversation id, through the real adapters' commands and the production process-line parser. Cost: 3 files +32/-6. No new refusal or error code.
A Codex seat launch now finds the project and hook trust tables Codex already saved in `~/.codex/config.toml`, whatever quoting they use, and updates them in place. It never appends a second table for the same key. Reported by @borfast in #2029.
Codex saves its tables with TOML literal (single-quoted) keys, such as `[projects.'/home/me/app']` or `[hooks.state.'…/config.toml:session_start:0:0']`. OpenRig looked only for the double-quoted spelling, missed them and appended a duplicate. Codex then refused to start, and so did every other Codex session on the machine, until the duplicates were removed by hand.
If Codex already refuses to start with "trying to redefine an already defined table", remove the extra double-quoted table it names, once. The trust writers leave a config Codex can't read exactly as it is, so that one removal is the whole repair.
What changed:
- The four trust writers (`upsertCodexProjectTrust`, `upsertCodexHookTrust`, `upsertCodexHookDisabled`, `rekeyCodexHookStateText`) and the owned plugin's content-approval check (`planOwnedCodexContentApproval`) find a table by its parsed key, read with the adapter's existing `smol-toml`, not by its text. Bare, double-quoted and single-quoted keys, spaces around the dots and a trailing comment all match.
- A header-shaped line counts only when it really opens a table in the document: swapped for a probe header the document doesn't already have, the parsed document gains that table. A table example inside a multi-line string, such as a person's `developer_instructions`, is left exactly as it is, and the real table is written.
- When the document already defines the key another way (an inline table or dotted keys), the writer leaves the file as it is; Codex's own trust prompt stays the fallback. The trust writers also leave a config that doesn't parse as it is, since appending can't make it readable.
- The helpers live in `domain/toml-tables.ts`, shared by the adapter and the owned-plugin check. A person's other tables and values are unchanged.
Not in this change: the rest of #17's repeated trust prompt (Codex re-saving `[hooks.state]` inside OpenRig's marked hook block) is #2035.
Tests cover single-quoted, inline and multi-line-example cases through the writers, `deliverStartup` and `ensureCodexActivityHooks`, the owned plugin's renewal, an unreadable config, and a probe-named key. Cost against main: source +77/-12 (the adapter, the owned-plugin check and the new `toml-tables.ts`), tests +134/-1. No new refusal or error code.
On Linux, when the daemon has an absolute `XDG_RUNTIME_DIR`, a seat now gets it, together with `DBUS_SESSION_BUS_ADDRESS=disabled:`. Tools whose sockets live in the runtime directory (an MCP server's daemon, gpg-agent, rootless Podman) work in seats without per-tool setup, and the seat's D-Bus clients don't discover or autolaunch the session bus on their own. Refs #2030; reported by @borfast.
`disabled:` was chosen from the libraries' sources: libdbus 1.16.2, GLib 2.88.0 and sd-bus 259.5 fall back to the runtime-directory bus, or autolaunch, only when the address is unset, and refuse an unknown transport before opening a socket. A Linux control with a throwaway bus confirmed it. This is default hygiene, not isolation.
It applies to the scoped tmux launch (fresh launch, restore and handover successors) and the managed Claude launch. A seat's own `XDG_RUNTIME_DIR` (from `env:`, a one-off name or tmux `update-environment`) leaves both names as before, a seat's own address is kept, and an empty address is filled. Unchanged: `terminal.inherit_tmux_environment` on, macOS, and a daemon started without `XDG_RUNTIME_DIR`. Pi and OMP agent processes keep their fixed list.
Changed behavior, accepted deliberately:
- `systemctl --user` connects to `$XDG_RUNTIME_DIR/systemd/private` regardless of the bus address, so a seat can reach the person's user service manager.
- A shell startup file that sets only `XDG_RUNTIME_DIR` used to let a classic seat's clients find the bus. Now the seat keeps `disabled:`. Set the address in the seat's `env:` to keep the bus.
Known limit: Codex starts its stdio MCP servers with a fixed list of variables, so a Codex MCP entry still needs `env_vars = ["XDG_RUNTIME_DIR"]` in `config.toml`. Codex hooks and shell commands, and Claude Code's MCP servers and hooks, get it with no setup.
Cost: source +56/-8 (`seat-runtime-dir.ts` new, `tmux.ts`, `startup.ts`, `claude-managed-launch.ts`), tests +114/-3, docs +10/-1. No new setting, refusal or dependency.
`rig seat restart <codex-seat> --keep-conversation` reads complete when the same conversation resumed, and the seat can be messaged afterwards, when a directory Codex was given has a space in its path, such as a working directory named `my repo`.
Before, in that case, the restart reported incomplete ("no single Codex process resuming this conversation was observed"), `reconcile` read continuity unverified, and an ordinary `rig send` was refused. The same conversation was running the whole time.
The cause: the relaunch reused the running Codex's own `--add-dir`s, git dirs included, and placed them between `resume` and the conversation id. OpenRig identifies a Codex by its process line, which joins argv with spaces (both `/proc` and `ps`), so that line named `repo/.git` where the id belongs. A shell or `node` wrapper isn't involved; the proof follows the chain from the native Codex through `node` and `sh` to the pane.
The change, in `codex-runtime-adapter.ts`: for an in-place restart, the running `--add-dir`s are reused unchanged and now follow the conversation id. Nothing is resolved again, and the running seat folder isn't added twice.
Not changed here: an ordinary resume still puts its queue-state `--add-dir`, and the restore path its team-workspace argument, before the id. Those are read wrongly only when that path has a space, and they're a separate fix.
Tests: the real process-line parser and pane observer read the id from a relaunch whose working directory has a space. Cost: source +4/-3, tests +29/-1. No new refusal or error code.
The execution view counted post-claim transitions for every candidate, including blocked and closed rows whose pickup logic never reads the count. Restrict this caller’s correlated count to pending and in-progress rows. Preserve historical rows and bodies, project/mission filtering, park/wake output, and the live bookkeeping-only fallback in the same SELECT.
Validation: 122 affected tests and daemon typecheck pass. Independent Codex and Claude source reviews pass. QA independently repeated all 122 tests and checked full-document parity through the real views route for project filtering and claimed-pending pickup; both adverse cases passed. The recurring severe-stall investigation remains open.
The parity test sets aside the free-disk reading (`q6_parallelism.df_margin.available_kib`), which is taken live at read time and can differ between the two documents it compares; every other field is still compared.
When a seat's session is confirmed missing, the stuck sweep sends its finding to a live recovery seat. The finding's reason now says which rule chose that seat, so whoever receives it knows why it landed on them, and which setting to change if it shouldn't have:
- "... so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)"
- "... goes to approver@r, the operator seat (workspace.operator_seat_name)"
- "... goes to sender@r, the row's creator"
Before, it named only the destination. The recovery seat is picked over the same candidates, in the same order, with the same predicate, so routing doesn't change. The sentence for "no live recovery seat is available" is unchanged.
Tests pin the rule name for each of the three outcomes; the operator-seat case only checked the destination before. Cost: source +12/-3 in `queue-stuck-sweep.ts`, tests +5/-3. No new refusal or error code.
An ordinary `rig send` into a Pi v1.4.0 extension input or editor dialog now returns `target_needs_input` and types nothing. Before, the message could become the dialog's answer, because Enter submits it. The deliberate, reasoned and audited input route is unchanged.
This covers default 80-column panes, where the help row wraps over up to three rows, and tall editors whose opening rule is outside the transport's 20-line capture. An editor dialog is recognized by its bottom rule, the complete four-action help with exactly one blank row on each side (Pi's `ExtensionEditorComponent` draws a spacer before and after it), and the dialog's closing border. So a draft in Pi's own main editor that quotes the help text still sends. Inputs keep the field, spacer and nearby opening-rule checks. #2015's later-composer and fence veto and its six-row footer limit still apply.
Pi renderer source: earendil-works/pi v1.4.0 at `4a080252f06caf63a525d7873c5d94d3c23887e0`. The fixtures reconstruct that renderer; they aren't native captures.
Limits: this stays a bounded screen heuristic. Clipped or incomplete help, more than three wrapped rows and arbitrary custom extension UIs keep their prior classification. A draft that copies the dialog's complete chrome, spacers included, still reads as a dialog: the sender is told and nothing is typed.
Cost: source +29/-5 in `session-transport.ts`, tests +105. No new refusal, error code or native runtime change.
A Codex seat without a pod restores through `CodexResumeAdapter` (`rig up --existing`, or a restore after a reboot), and that path added none of the git metadata `--add-dir`s #2000 gave fresh, resume and fork launches. So a default workspace-write seat restored that way couldn't write its worktree's `.git`, and commits failed. It now gets the same git dirs from the same resolver, then its seat folder, after the conversation id, so the `ps` proofs still read the id. They're added only on the plain workspace-write floor, since a named profile isn't checked for read-only on this path and Codex refuses a writable root under a read-only sandbox. Seats in a pod restore through the launch adapter, which #2000 already covers.
Tests use real temporary repositories: an ordinary repository's restore adds `<cwd>/.git`, and a linked worktree's adds its worktree and common git dirs, never the `.git` file.
Claude members that share a project folder can keep distinct, durable tool rules. Declare `claude_permissions.allow`/`deny` in the spec, or replace a retained seat's rules with `rig seat set-permissions <seat> --rules <json-file> --reason <why>`. The update is audited and takes effect at the next managed launch or resume. It doesn't restart the seat or change its permission mode.
Migration 139 stores one current record per node. Fresh startup, resume, legacy restore and same-seat handover read it, and a fork copies the source's current rules independently. Rules compose into the existing single `--settings`, keeping defaults, hooks and compaction settings, and are never written into the shared project's Claude settings. Status and export show the current plan. Stored Claude rules stay dormant while a seat runs another runtime and apply again when it returns to Claude.
A stored record that's invalid or unreadable refuses that seat's launch, fork or export with a repair message, rather than launching without its denies; the fork API reports `permission_rules_unavailable` (409) before creating a child.
Native check on this head (Claude 2.1.309): two members in the same folder get their own rules (one reads and writes a file the other is denied), a user-wide allow doesn't override a member deny, managed policy wins, and a setter change applies at the next launch, not in a live process. Not proven: that handover and restore keep the same conversation (the rules are carried in the launch arguments), and deny enforcement under bypass mode, which isn't claimed.
Limits: allow lists don't withdraw inherited grants; these are native tool rules, not OS or credential isolation. The pre-feature reader `75c1f045` opens the upgraded database but leaves the rules out of launch arguments, so before going back to older code, set equivalent native rules yourself.
Cost at this head against main: source +206/-27 in 24 files, tests +416/-7 in 13, docs +54 in one. One migration and one new error code.
A draft-aware message to a Claude seat pastes its text, then checks that the composer still holds only that paste before pressing Enter. When Claude replaces the mode footer with "paste again to expand" after the paste, that check read the composer as changed, so the message never submitted and later wakes could stall behind the leftover paste. The shared reader now treats those recognized noninteractive footer hints as part of the same composer. The lease, the original-empty check, the target and occupant, geometry, cursor and paste-label checks all remain. No extra input, delay or retry.
Refs #14. A native A/B on a fresh Claude seat delivered the long message and its dependent wake on both arms, so it shows no regression, but the original failure didn't reproduce, so #14 stays open.
Coming back to a Herdr view with `rig terminal open <view> --provider herdr` always adds a new numbered workspace; reusing an open one is the `--window` path from inside Herdr. The shipped `openrig-herdr` skill told agents to reopen with the explicit form, so people got a duplicate workspace. The skill, the getting-started guide and the capability map now say to focus the open space, or from inside Herdr run `rig terminal open <view> --window`, which reuses a matching space, and that `--provider herdr` adds a workspace each time. The `--provider` help text says the same.
A known-issue line: Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting (`needs-input`), because OpenRig doesn't feed Herdr a state. Use OpenRig's view or the Feed to find waiting seats.
Text only; the skill's mirrors and digests are updated together.
A seat restored with `claude --resume` can stop at Claude's resume chooser ("How would you like to resume?", or a numbered conversation list). In a tall pane OpenRig reported it as resumed instead of asking a person to choose, and a Codex model chooser in a tall pane read as `awaiting_runtime` instead of `model_selection_gate`. The reverse happened at the bottom of an ordinary pane: an old chooser above a resumed session's composer read as live.
Both choosers are drawn at the top of a fresh pane, and tmux keeps the blank rows under them. Each window now ends at the last drawn row, and a chooser counts only while it's the last thing drawn. For Claude, nothing of Claude's own may follow the last option: a composer, a `Claude Code v` header or a reply retires it, and Claude's input frame (a rule, the `❯` row, a rule; the top rule carries the session name on a renamed seat) counts as the composer. A bare `❯ N.` option row under a live chooser is still an option. A conversation list's heading is read from the whole capture, so a long live list still reads as the chooser. For Codex, the options count only when no composer row or `OpenAI Codex (v` header follows them. The probe still never answers a chooser.
Known limit: a frame drawn under a still-live chooser would read as resumed; no recorded layout does that.
Tests: tall and bottom-reaching live choosers for both harnesses, history above resumed sessions, long lists, numbered drafts in labelled and plain frames, and the restore orchestrator's chooser cases.
On the default workspace-write floor, a Codex seat's own topology folder (`<topology root>/rigs/<rig>/seats/<seat>`, home of its `LEARNED.md` and recap) sat outside its writable roots. So the seat couldn't keep its own `LEARNED.md` for its next occupant, and `rig context recap-write` failed with EPERM. The folder is now added as an `--add-dir`, created first if missing (including the rig's folder), because Codex can't create a folder outside its writable roots.
It follows the git metadata dirs' rules: fresh, resume and fork add it after the git dirs, and on resume and fork after the conversation id, so the `ps` proofs still read the id when the path has a space. A rebuild keeps the observed envelope, a provably read-only profile gets none, and an in-place restart keeps the running process's own `--add-dir`s. The restore path's resume adds it only on the plain workspace-write floor. The name must be canonical with safe segments, and a symlinked `rigs`, `<rig>`, `seats` or `<seat>` segment gives no folder and creates nothing through the link.
Because the seat can now write its own folder, and the daemon writes there for it through the recap route with wider file access, the recap store now refuses to write through a link at `RECAP.md` or `recap-superseded/` (dangling links included). That's an intentional refusal of a layout that worked before. The current recap, the archive and the link's target keep their bytes, and the error is the existing `recap_write_refused`. The direct write, the daemon fallback and folder creation are unchanged; the check runs before the write and doesn't cover a change made in between.
Codex inline hook trust was keyed as if the managed hook group were always at position zero, so OpenRig's trust entry could miss the hook Codex actually runs. Trust keys now use the managed group's actual position, the one Codex reads. A person's own trust hashes and disable choices are preserved, nothing new is trusted, and an old zero-position entry fails safe. The JSON hook route and hash algorithm are unchanged. Refs #17.
Thanks to @dajiaohuang.
A service bundle installed on a machine without Docker Compose v2 used to leave a half-made rig. Install now runs a read-only `docker compose version` before the install target is written and the team is created, and stops with `compose_missing` if it fails: "The install target wasn't written and no team was created." A preview (`rig bundle install --plan`) still returns its `planned` result and `services_plan` stage, and reports the failed check as a warning that an install on this machine would stop first. No Docker daemon is needed for the preview. The message keeps Docker's own reason line, not its trailing `See 'docker --help'`.
When `compose up` or `compose down` fails with Docker's own missing-plugin words, the error names the missing Compose. With Compose missing and no containers labelled for the project, there's nothing to take down, so an install's rollback can remove the rig. With containers, or with Docker unreadable, it still fails and names the missing Compose. Empty container inventory doesn't prove volumes or networks were removed. Plain `rig down` keeps the rig record, and `rig down --delete` behaves as before.
Bundles without services never run the check.
`transport-routes.test.ts` checked that a timed-out `--wait-for-idle` send returns 409 and sends no text, using a 1 ms wait in real time. On a loaded runner the first pane observation could finish after that wait, which by design reads `target_activity_unknown` instead of busy, so the test failed now and then (it failed one required CI run). A fake clock now drives the expiry, so the test checks the same outcome without depending on runner speed. No product code changes.
When a seat dies holding work, the stuck sweep's finding now goes to a live recovery seat when one is available, in this order: the operator agent (`agents.operator_session`) when one is configured, the operator seat (`workspace.operator_seat_name`), then the stuck row's creator. A candidate is skipped if it's unset, if its own session is confirmed missing, or if it's the dead seat itself. If none qualifies, the finding is still created, since it's owed work, stays addressed to the missing session, and its reason says no live recovery seat was available. A finding that can't be created is reported as a failed pass, as before.
`workspace.operator_seat_name` is the person approvals reach (the workflow human resolver and Mission Control's default queue read it), so it isn't repointed. `agents.operator_session` already named the operator agent's session for the UI's operator chat; the sweep now reads it first, so recovery can reach an agent while approvals stay with the person. A person's address (`@external`, or a human seat such as `human@kernel`) never counts as the operator agent. With the setting unset, routing matches the previous behaviour whenever that fallback is eligible; the self and confirmed-missing filtering now applies to the operator seat and creator too. The wake ladder is unchanged.
Tests in `queue-stuck-sweep.test.ts` cover the agent-first route, a missing agent passed over, the no-live-seat case two ways, and how the setting resolves.
The installed prompt-state scenario (#2002) drew its working Claude frame at the bottom of the pane, so it couldn't catch the tall-pane case #2005 fixed: a fresh seat draws its input box near the top of a tall pane, with blank rows under it. The scripted pane is now 45 rows, with the frame inline and top-aligned and the cursor in the input. Admission checks the real pane height (at least 40), the exact full-screen row count, a drawn mode footer and at least 20 blank rows below it. New controls reject short geometry, truncation and an all-blank capture. #2002's other controls are unchanged.
On #2005's parent (`a5289131`) the scenario stops at the named #1955 assertion: the quoted-selector working send is refused, in a 45-row pane with 35 blank rows. At this head it passes. Test and docs only, 3 files, +23 -10.
`startup_assisted: true` means a person checks startup submission after the attempt. The first turn may already be running, including when `first_input` passed it at launch, so the docs keep the no-resend guidance and say plainly that it's a check, not a hold.
The Cursor reference preset now uses `startup_assisted: false` with its existing positional first prompt, and the obsolete assisted-paste limit is removed. In native qualification at `802d44a2` (Cursor Agent 2026.10.22-27dcf5f), a 1996-byte first prompt arrived as one positional argument and got the expected first reply, and same-chat snapshot resume worked. Later native tool approvals remain separate, and full unattended tool-workflow completion isn't claimed.
Runtime code, the launcher and the orchestrator's assistance cases are unchanged. One test assertion follows the preset.
OpenClaw seats can run either an embedded local agent or a TUI client of an existing Gateway. The reference launcher keeps OpenRig's named conversation, checks the exact native key at its owner before resume, and passes the generated first startup input through `--message`. Both examples use the existing format-1 `names_conversation`, `resume` and `first_input` fields, and each seat keeps its own client state. An unavailable lookup is distinct from a confirmed missing key, and neither silently starts a replacement. Stopping a Gateway client detaches it without stopping the Gateway or an accepted turn.
Native qualification on `8f3d7a6c` with OpenClaw 2026.10.6 covered the Gateway journey (first input, a task, snapshot, same-conversation resume, a useful reply with no startup replay) and, for the local agent, first input, a task and a readiness-gated resume of a completed conversation in the same session UUID. A resumed process doesn't mean the native TUI has finished its database startup checks, so the guide says to wait for native readiness before sending work. A local turn interrupted by a stop may leave native recovery work that refuses a later request; the local preset says to finish the current turn before stopping, and this launcher doesn't repair or retry it.
No core plug-in contract change. Docs, presets, the launcher and one example test.
Queue history reads call the wake lookup once per transition. Preparing the same indexed statement for each call added avoidable work to health-diagnosis reads with long histories. Prepare it lazily once per repository instance and keep every lookup fresh; no query results are cached. Availability, ordering, misses and errors retain their behavior.
Validation: 140 affected tests and daemon typecheck pass. Independent Codex and Claude source reviews passed on the identical two-file change. A real-database test checks a warmed miss, insertion, update and deletion.
In one synthetic daemon replay with 100 owned diagnoses and 6,900 history transitions, reference/candidate/candidate/reference evaluation means were 918/759/745/1,014 ms: combined 966 → 752 ms. All 12 evaluations retained complete coverage and all 16 native-consumer checks passed. Reference drift was 96 ms and individual ranges overlapped. This is a local improvement on that workload; the recurring severe-stall cause remains unresolved.
`rig queue resolve` now also releases an item parked on a typed blocker, such as `external:<what>`, `fold:<what>` or `auth:<what>`, once that blocker is met. The supplied evidence is recorded with the transition as a wait resolution, not as a human decision, and the item moves back to in progress with its owner nudged, as `resolve` already does for an item waiting on a person. Items blocked on another queue item, malformed blockers and items that aren't blocked are still refused. The mission-control architecture doc describes the widened eligibility.
This is the widening agreed on #1785 instead of a new `satisfy` verb. Thanks to @dajiaohuang for the change and to @BillyOutlast for the report from their fleet.
An adopted Pi TUI can keep an unknown runtime and has no managed-runner hook, and the shared pane classifier didn't recognise Pi's extension selector. So an ordinary `rig send` into a window waiting at an approval dialog typed its text and pressed Enter, which chose the highlighted answer (by default Yes).
The classifier now recognises the current Pi selector by Pi's own chrome: an option block with exactly one arrow-selected row, the navigate/select/cancel help row, and the closing border after it. The footer isn't read, so unnamed sessions, missing usage counters, extension status rows and below-editor widgets don't decide recognition, and rebound keys and any choice labels work. A later editor border or a closing Markdown fence marks the selector as history or a quote. The existing prompt guard then refuses with `target_needs_input` and writes neither text nor Enter. Ordinary unknown-runtime sends and the audited deliberate-interaction override are unchanged.
Limits: up to six non-blank rows may follow the selector's border (the footer, a status row and widgets). Past that, or under a widget that draws its own border, it fails open to the previous behaviour. Pi's text-input dialogs aren't selectors and aren't covered.
Tests: `send-prompt-guard.test.ts` covers ten layouts with null, unknown and Pi runtimes, selected No, history and quoted output. Source +24, tests +85.
A default workspace-write Codex seat couldn't save its handover recap: `rig context recap-write` wrote the topology seat folder from inside the seat's sandbox and failed with EPERM, so a rebuild recovered only from git.
The CLI still tries the local write first. Only when the OS refuses it (`EPERM`, `EACCES`, `EROFS`) does it send the recap to a new `POST /api/context-packs/seat-recap`, and the daemon writes it. Both paths use one `writeSeatRecapAt` for the address checks: bounded names, an existing rig, no symlinked or file segment in the rig, `seats` and seat folders, and the existing supersession and addressability rules. A link already at the recap file or its archive folder is followed, as the local path always did. The Markdown heading parse the route uses is now a linear scan with the same accepted headings, so a long recap can't stall the daemon.
Validation: CLI and route tests cover the fallback, a timeout reported as UNKNOWN, an unreachable daemon and each address check; the heading scan matches the old regex on a large differential set and stays fast on the inputs that made the regex slow.
Moving between TUI pages forgot the activity stream's connection state. A new page treated a connected stream as disconnected and polled every 30 seconds instead of every five minutes, and returning to a cached page after a disconnect could bring back `connected` and wait five minutes for its next read. Connection state now belongs to the live refresh owner, outside cached page loads, so navigation keeps the real state and its cadence.
Thanks to @Nikhi00718.
When a seat's session disappeared, the stuck sweep addressed its recovery finding to that same dead seat, and the seat kept reading idle at prompt, so nobody was told.
A stuck-sweep finding whose route is a seat with a confirmed-missing session now goes to the configured operator seat, or else to the stuck row's creator, the floor the wake ladder already uses, and its reason names the missing session and where the finding went. That seat's activity reads unknown on the queue's surfaces. The check is one predicate reusing node inventory's own confirmed-missing rule, so no verdict, a stale verdict from before a relaunch or handover, or an unreadable store still reads not-missing. Live seats keep their findings, and orchestrator routing is unchanged.
Validation: the sweep, wake-ladder, node-inventory and projection suites pass, and the three routing cases fail on the old sweep. Daemon typecheck passes.
Agents asked how far to trust a delegated result didn't reach the delegating-work skill, although its body already covers that. The skill's description now ends with one trigger sentence: "Also use it to decide how far to trust a delegated result." The body and every other byte are unchanged; the shipped copies and digests match.
In a lab measurement (Sonnet 5.5, Claude Code 2.1.310, one skill listed), the skill activated on 5 of 5 runs for each of two target prompts and on 0 of 36 near-miss runs. There's no claim for other harnesses or models.
Thanks to skills-sme@lab, and to Matt Pocock's writing-for-agents and Lauren Tan's pstack Eval, which shaped the method.
`rig provider switch --seat <seat> --token-file <path>` moves one Claude seat onto a prepared subscription token, on the same conversation, the same config directory and the same pane. `--stored-login` moves it back to the config directory's stored login.
- OpenRig stores a reference to the token file (a label and path), never the value. The launch reads the file as Claude starts and exports it only into Claude's process, so the value never reaches OpenRig's database, rows, argv or logs.
- The seat keeps its pane: Claude ends in place, a shell respawns in the same pane with its scrollback, and restore's own resume step brings the conversation back.
- The report names the source the seat actually bills. The token survives the managed environment reduction. Variables that would outrank it are unset for that one process, which also hides them from Claude's tools and hooks. With `--stored-login`, an inherited `CLAUDE_CODE_OAUTH_TOKEN` is reported rather than silently used, and a launch-time fallback warning reaches the report.
- Outcomes are honest. Only a refusal before Claude is touched reads as unchanged. A pane that was ended but not resumed, or a thrown step, reads `unknown` with the snapshot's recovery command, and nothing is retried.
- The whole switch runs in the seat's existing lifecycle lease, so a second switch waits and rechecks. A failed registration never kills a kept session. The printed way back quotes the token path.
One nullable column (migration 138) and one event type. No account registry, vault or new route. Native validation on prepared homes is next.
A `rig send` to a Codex seat that is mid-turn printed only "target pane appears mid-task; sent anyway". Codex folds such a message into its running turn, and in a measured run the turn then answered only the new message.
When the target seat runs Codex and no `--wait-for-idle` was passed, the advisory now adds that the message joins the running turn and may redirect it, and names `--wait-for-idle <seconds>` for separate work. `docs/reference/seat-delivery.md` says the same. Other runtimes keep the existing warning; sends stay advisory, with no new mode, refusal or default.
Validation: the existing busy-send test now covers Codex and Claude, and the session-transport suite and docs guard pass.
After a team's tmux server dies (a crash, OOM or kill) and leaves its socket file, `rig down` reported "Could not confirm session … is absent: no server running" for every seat and stopped nothing, so `rig archive` refused live sessions and `rig up` refused a running rig.
`rig down` now treats tmux's own "no server running on <socket>" diagnostic from the daemon's probe as positive absence: it records the sessions as intentionally stopped and clears their bindings, without killing anything or starting a server, and archive and up then proceed. The rule lives in one shared predicate beside `SessionProbe`, used by teardown and the import guard, and matches only the start of tmux's diagnostic (stderr, or the line right after a `Command failed:` wrapper). A missing socket file, a permission failure, socket-path text and any later line stay uncertain, as before. The session-absence check uses the same start-of-diagnostic boundary, and the probe rethrows the original error so its reason stays readable.
Scope: a reboot that clears the socket directory is not covered. Validation: focused teardown, adapter and guard suites pass, including path-text, newline-path and mixed permission controls that kept the rig and binding, and daemon typecheck passes. Native acceptance (kill a disposable team's server, then down, archive and up) is separate.
A prompt visible only in a seat's pane must appear as needs-input across the API and CLI, and quoted approval text above a working Claude composer must let coordination messages through, while an actual waiting prompt still refuses them. Both regressions escaped to users (#1946, #1955).
Adds one installed `prompt-state` scenario using adopted scripted panes, the real daemon, CLI and tmux path, actual timer wakes, and a read-only check that no hook supplied the activity. It checks that attention clears on a working pane, that both quoted-text forms admit a send and a wake, and that a real dialog refuses both; the refused wake is tied to the watchdog's own attempt record and refusal reason. No production source or provider calls.
Validation: the installed case passes at this head in about 21 s in its disposable container (peak memory not sampled). On rebuilt historical sources it fails at the named assertions: the pane-only attention check on 32633a3d, and the quoted-selector working send on the parent of db863940. The scenarios use controlled pane layouts and don't prove native runtime behaviour.
A fresh, working Claude seat in a tall pane (40 rows or more), with quoted approval text above its input box, refused sends and queue wakes as if it were waiting at a prompt, which is #1955's bug again. It also happened just after a cancel or clear redraw. Claude Code draws inline, so the rows under its footer stay blank, tmux keeps those blank rows in a capture, and `findClaudeComposer` took the last 20 physical lines before dropping blank ones, so in a tall pane it saw only blanks.
The 20-row window now ends at the last drawn row. The bound and column preservation are unchanged, and the status scan above the box still stops at the nearest unindented row. The draft-aware typing guard and the running check use the same function.
Validation: the tall-pane cases fail on the old source and pass after. Every dialog layout drawn high, with blank rows under it (permissions, the question picker, the MCP menu), still reads as attention, and sends to them are still refused. Native confirmation in a tall pane is separate.
A person who declined `rig setup` could still get Codex config writes when the kernel started, and a project task handed to the kernel operator could be implemented there instead of by a team.
The installer guide now says, before installation and startup, what each step writes: setup writes `~/.tmux.conf`, and starting Codex seats writes workspace trust and selected config fragments to the home `config.toml`, which skipping setup or turning hooks off doesn't suppress. A separate Codex home also selects login and session state, and a project config or worktree doesn't redirect these writes. It explains why individual approvals prompt often, keeps the one optional remembered-rule offer, and rules out broadening other settings to avoid prompts. The operator's role and startup context put project work on the chosen team's queue.
Guidance only: no setup mode, configuration or runtime change. Validation: docs guard, the first-run reference checker and the global-skill fixture checks pass, and the canonical and packaged `rigs` skill copies match. Whether agents follow it is for the first-user checks.
On Linux, `rig seat restart --keep-conversation` rebuilt a Codex seat's resume from `ps` output split on whitespace. With a cwd such as `/home/u/my repo`, the restart passed `--add-dir '/home/u/my'`: the seat lost write access to its `.git` and gained it on an unrelated prefix.
The restart now reads the running process's exact argv from `/proc/<pid>/cmdline`, next to the `/proc` reads it already makes, and uses it for the writable roots, network and daemon options, and the settings and effort comparison. A value containing a space no longer reads as settings drift. If `cmdline` can't be read, it falls back to `ps` as before. Nothing new is refused. The process facts are still read separately, as before; this is not a guard against pid reuse.
Validation: the new restart case fails on the old source and passes after, and the restart suites and daemon typecheck pass. The real `/proc` reader test runs only on Linux, so CI's macOS runner skips it; the NUL-splitting parser is tested on every platform.
Contributors now get the correct vercel-labs/agent-browser source link and an accurate description of our retained copy, rather than an unsupported "tracks upstream" claim. Its upstream Apache-2.0 license travels beside it and beside dogfood; the three shipped obra/superpowers derivatives (systematic-debugging, test-driven-development, verification-before-completion) also include their upstream MIT license. Check dates name the compared upstream revisions and say no content sync occurred.
Skill bodies, triggers and companion workflow files are unchanged. Mirrors, edge digests, the docs guard and the exact upstream license bytes are verified. Metadata, notices and regenerated hashes only.
Thanks to the authors of vercel-labs/agent-browser and obra/superpowers.
The dashboard shows a neutral "Boot not tracked" badge for `kernel_state: "skipped"`, keeping the daemon's reason visible. An existing managed kernel and an explicit auto-boot opt-out can both produce this state; ready-agent counts do not turn it into a healthy verdict. Unavailable or malformed responses still show "Unknown".
The added component cases fail on unchanged production code and pass with the fix; they exercise the real query hooks and rendered card with synthetic HTTP responses. The dashboard documentation describes the new state.
Reported by @Kero-66 in #1915.
A Codex seat in workspace-write could commit, but after `rig down --snapshot` and `rig up --existing` it resumed without `.git` among its writable roots and could no longer commit. The fresh launch adds `--add-dir` for the seat's git metadata (#121); `codex resume` and `codex fork` were sent without it.
`prepareLaunch` now resolves the git dirs once per launch, with the same rules as before: a linked worktree's real git dirs, never a `.git` file, and none for a provably read-only profile. Fresh, resume and fork all pass them, and a fresh launch's command is byte-identical. A rebuild keeps its observed permission envelope, and an in-place restart reuses the running process's own dirs. On resume and fork the git dirs follow the conversation id, so the `ps`-based resume and fork proofs still find the right process when the cwd contains a space; Codex 0.164.0 parses options after the id as options.
Validation: 11 resume and fork cases fail on the old source and pass after; the Codex adapter suites pass, including new cases with real temporary repositories and linked worktrees. Two independent reviews, one with a security lens. Native acceptance (commit, restore, commit again) is separate. The pod-less legacy resume path is unchanged.
Unknown-readiness diagnosis currently runs a full tmux environment listing for each relay prerequisite alias while holding the seat’s send lease. Read requested presence booleans from one fresh listing instead. Keep scalar fallback for custom adapters; a failed batch returns UNKNOWN without exposing values. This intentionally uses one snapshot and one failure outcome rather than separate alias reads.
Validation: 559 focused tests and daemon typecheck pass; independent Codex and Claude reviews pass. A single instrumented comparison recorded lower preflight intervals, but both arms still had one five-second timeout. This removes redundant subprocess work; it does not claim to eliminate send timeouts.
CHANGELOG 0.7.1 now includes the service-bundles entry already published in the 0.7.1 release notes, as the last "Running a team" bullet before "Reliability".
The entry matches the published body after whitespace normalization and the changelog's heading nesting, and it is the only insertion. Documentation only: no release, package or version change.
A Pi extension dialog is shown in the seat pane and cancelled, with the seat marked as needing attention. The signal survives turn end and idle heartbeats until a new agent run starts. Extension notifications are shown without generating a response or attention.
The runner reuses the existing RPC cancellation policy: it replies only with the request's own id and cancelled: true, so nothing is approved, and it cannot answer dialogs in the pane. The pane explains the cancellation and asks the operator to review the request. Dialog title and message are stripped of terminal and control characters and capped before display. OMP behaviour is preserved.
Validation: five new controls fail on the prior source and pass after the change (the four dialog methods and a notification); the Pi and OMP runner suites pass with one worker. Two independent reviews, one with a security lens. Real Pi acceptance is separate; no native process or provider call ran.
The packaged-plugin baseline now records the published 0.7.1 instead of 0.7.0: tarball sha256 c1637bbe (matching npm's integrity) and openrig-core 0.1.18. It's generated by the script's own refresh command from `npm pack @openrig/[email protected]`. The 0.7.2 cut's plugin check compares against the release users actually have.
Fixture only: +4/-4. No code changes.
`rig provider switch`'s printed undo and `rig auth switch` restore a saved Codex login's bytes. They now say only that. The output says sign-in is unverified, that saved credentials may expire, be revoked or be superseded by a refresh elsewhere, and that you should restart the affected seat and use the existing account verification, since a fresh sign-in may be needed.
Before this, the undo printed `activated_profile` and exited 0, which reads like restored access. Codex keeps a refreshed token only in its active `auth.json`, so an older saved copy can go stale.
Text only: +23/-3. No behaviour, refusal or JSON change.
A backlog of queue notifications can hold startup in sequential verified deliveries until the CLI readiness deadline expires. Start the daemon-owned recovery pass after listening. During shutdown, finish the delivery in flight and stop later claims, including released members of combined wakes; unattempted notifications remain for the next start.
Validation: 125 focused tests and daemon typecheck pass, with two independent source reviews. An independent four-stub-seat, 40-notification replay reproduced the reference readiness failure at 21.17s; the repair reached health at 2.96s and attempted all 40 (20 render-verified, 20 awaiting confirmation). Consuming-agent acknowledgments and large-database startup remain unqualified.
Main now carries what 0.7.1 published.
- `CHANGELOG.md`'s 0.7.1 entry is the published v0.7.1 Release body, word for word, reflowed to the file's line form. That includes the credits for all reporters and all 22 known issues. It replaces the earlier draft the tag carried.
- `docs/reference/whats-new.md` carries 0.7.1's entry from the tag, with one line narrowed to what's true at source.
- The root, cli, daemon and ui package versions and their lockfile entries move from 0.7.0 to 0.7.1, matching the v0.7.1 tag. The packaged whats-new must name the package's own version.
No other changes.
A community-harness preset can now set `first_input: true`. OpenRig then hands the harness its first startup turn at launch, through the harness's own route (OpenClaw's `--message`, Cursor's positional prompt), instead of pasting it into the pane after start. Without this, OpenRig could report a seat ready while its identity text sat unsent in the composer.
- OpenRig composes exactly the first startup text it would have pasted, writes it to a per-attempt 0600 file in an OpenRig-owned folder, and passes `OPENRIG_FIRST_INPUT=<path>` at launch. The preset's launcher hands it to the harness.
- Only that one send is skipped. Later startup files, `after_ready` actions and ordinary sends go through the pane as before.
- The record says the first input was passed at launch, not confirmed submitted, and only once the transport actually attempted input. An uncertain launch never replays it.
- Readiness stays process-only, and `startup_assisted` is independent. Built-in runtimes and presets without the field don't change.
The two reference launchers (OpenClaw, Cursor) use it. Argument-based harnesses see the first turn in their argv, which may face platform argument-size limits; that's in the presets doc.
The MCP read tools `rig_status`, `rig_slices`, `rig_mission`, `rig_context_pack` and `rig_context_get` were tested only against a mocked client. Now each runs through the daemon's real routes too, so a change to `/healthz`, `/api/slices`, `/api/missions` or the context-pack library response fails CI instead of silently breaking the tool for an MCP client.
The cases check what a client would see: healthy status, filled slice and mission summaries, a cut page that says so, pack and file contents, and no local path in any answer. `rig_status` covers only the healthy pass-through; an unhealthy daemon's mapping isn't exercised here.
Test only: +77/-2 in `packages/cli/test/mcp-operations-daemon.test.ts`. No source change.
A focused TUI view that shows a health summary (the instance Table, the rig and pod Table, and Overview) now loads the existing health projection that summary displays. Served findings and coverage appear after hydration instead of staying at "Not assessed". Other focused views keep the findings read lazy.
An empty projection still reads as "not a healthy verdict", and a 503, an unrequested read or a remote-host summary stays honest. Pod summaries keep the rig scope. The still-resolving `pendingDrill` first frame isn't covered.
This carries @Kero-66's original fix and report forward, with their commits and authorship kept, through @Aschenbath's resubmission. Thank you both.
When `rig queue create` gets no answer in time, or an unreadable one, the CLI now reads the row back once by its ID before it reports anything. The create is never sent a second time.
- A row that is exactly this create is reported as created: "The create committed after all: row … (read back by its ID)", exit 0, printed as for any create, with `--verify`'s delivery result when it was asked for.
- A row with that ID that isn't this create stays INDETERMINATE and names what differs (destination, source or body), with "Don't reuse this ID for other work".
- No row yet, or a read-back that fails too, stays INDETERMINATE and says why. The `--id` retry is kept.
"Exactly this create" means the same destination, the same body, and the source the daemon actually stores for this request. The CLI takes the sender it actually sent, canonicalizes it as the daemon does (trimmed, its own host suffix removed), and stamps the forwarding daemon's host id only on a genuine forward. A `--host` naming the daemon's own id is a local create, unstamped, by the daemon's own self-host rule. When the host id it needs can't be read, the outcome stays unknown. No host suffix is optional.
Before this, a daemon that answered after the deadline left the person with a failure or an unknown outcome while the row already existed, so an agent could create the work twice. #1911 made the timeout read INDETERMINATE; this adds the read-back on top. No new refusal, replay, route or flag.
`docs/reference/seat-delivery.md` said the typing guard has three modes. Since #1770 it has six. The page now lists `openrig-urgent`, `urgent-only` and `sends-only` alongside the others, in the intro, the mode table and the `--mode` line, using the CLI help's words. It also states the existing rule that lifecycle work is refused at `openrig-urgent` and `urgent-only`.
Docs only: +10/-5, one file.
The internal testbed image moves from Node 22.22.1 to 22.23.4 (CVE-2026-111497). Its download is checked against Node's official SHASUMS256, and the check binds the extracted archive, so a mismatch stops the build.
The scenario runner now settles its containers by recorded id instead of `rm -f`. It removes the cidfile only after a confirmed removal, so a re-run into the same folder isn't refused, and it prints a retained container's name.
This image isn't shipped. Its rebuild hasn't been qualified on its own: the first ordinary run of the route that consumes it is the proof.
After a power loss and `rig start`, a team of plain terminal seats that restored fresh now reads `fully_restored` instead of partial. A `builtin:terminal` seat runs no agent command, so a fresh shell is the whole seat. #1328 already relaunches such a seat fresh, but the restore rollup counted it as a lost conversation.
Restore now records `noConversation` on an ordinary terminal seat's result, using the same predicate that relaunches it fresh, and only when its saved startup actually ran. These still make a rig partial: an agent seat started fresh, a terminal with a resume token or a changed runtime, a terminal whose saved startup failed, and any failed, waiting or attention seat. `rig restore status` keeps older attempts' recorded verdicts.
`rig down`'s notice now names the terminal sessions it stops.
The real check stays Fleet's power-loss run on the 0.7.2 candidate.
ROADMAP.md's "Planned for 0.7.1" heading is now "Planned for 0.7.2", since 0.7.1 is out. It lists 0.7.2's selected themes, each marked "(if ready)": outside assistants over MCP, more harnesses as seats (Hermes, OpenClaw and Cursor), per-seat accounts, member permissions, stream decisions, and named people in Slack. The existing Subscriptions line moves with it, unchanged. No dates and no other changes.
A signed-out Codex 0.162.1 seat reports that sign-in needs attention and gives the existing `rig seat continue` hint, instead of waiting for the readiness timeout. This extends Aschenbath's #1967, which did the same for Codex 0.164.0, to the older chooser: its two-line introduction and three numbered choices.
The same shared matcher accepts the older heading as a second form. The panel must be complete and current: both heading lines exact, exactly three choices in order with one selected, and the continue footer last. Partial or quoted panels, unverified wrappers and a newer composer don't become sign-in gates. No text or keys are sent. Existing 0.164.0 matching is unchanged.
The available 0.162.1 capture covers the chooser only, so its API-key entry screen isn't newly qualified.
Source +11/-4; tests and fixtures +39/-17. No new setting, reason code or dependency.
Four lines in docs/as-built/arteries.md's regression log, in its existing form: date, issue, artery, and which layer missed it. Nothing else in the file changes.
- F1 (0.7.0 candidate), launch: a fresh Codex kernel stopped at Codex's hook review. Caught at the cut by native first-user and daily runs; missed by the stub layer. Fixed in #1824.
- B6 (0.7.1 candidate), launch and resume: an upgraded Codex seat stopped at "Hooks need review: 6". Caught at the cut by a native upgrade run; missed by source tests, stub scenarios and the installed-upgrade check. Fixed in #1948.
- Pane-only attention (0.7.1 candidate): a Codex seat at a prompt only its pane showed read as working. Caught at the cut by real work; missed by fixtures that all carried hook signals. Fixed in #1946.
- #1955, delivery: quoted approval text in a working Claude seat's output read as a waiting prompt. Present since 0.7.0, found during the 0.7.1 cut by real work, and fixed for 0.7.2.
The log exists so each line becomes a scenario; where one is owed, the line names it. Docs only: +4/-0.
ROADMAP.md stops listing work 0.7.1 already shipped. The five Planned 0.7.1 entries that the published release delivered are removed: team skills, interruption levels (including terminal presets), permissions, bundles, and community contributions, each matched to its merged milestone PRs and the published 0.7.1 release notes. The subscription entry stays, because 0.7.1's boundary record marks that increment partial. Nothing else in the file changes: no rewording, dates or new entries.
An agent can move one Codex seat to another subscription login with one command, and the seat keeps its conversation: `rig provider precheck --seat <seat> --to-account <name>` shows the Codex home the running seat uses, what the seat is doing and every peer on that home, and `rig provider switch --seat <seat> --to-account <name>` performs the move. Before this, `rig provider switch` always answered `failed_safely` with `switch_execution_not_yet_wired`.
How it works: the switch composes `rig auth`'s file operations, now in the daemon (`@openrig/daemon/codex-auth`, with the CLI path a re-export), and the in-place `rig seat restart --keep-conversation`. It plans the restart first, which stops nothing, and checks that the login is saved in that home. It then saves the outgoing login as a unique `before-switch-<time>` copy, never replacing an earlier one; a save that fails for any reason other than "no active login" stops the switch before anything changes. It activates the chosen login and restarts the seat in place. The report reads `rebind_in_progress` with the verify step, `failed_safely` with the way back, or `unknown` with the restart's recovery command, never retried. Switches on one Codex home run one at a time inside the daemon, and a switch that waited behind another rechecks its seat before it changes the login. Every same-home peer is listed with what will happen to it, from Codex 0.164.0's own source: it keeps its old login in memory until its next token refresh, then stops with "signed in to another account". Each peer gets a pasteable `rig seat restart <peer> --keep-conversation --reason ...` step, and the person is told not to sign in again in that home. After a CLI timeout, the message says the switch may still be running or queued behind another.
Limits: Codex seats only (a Claude seat is refused with `rebind_unsupported_for_runtime`), Linux only (the restart reads `/proc`), and the login must already be saved in the seat's own home. A restart that never settles holds its home's queue; switches there wait, and their CLIs report unknown after 120 s. Auth contents never enter returned metadata, logs or argv: files are copied by descriptor through a scrubbed buffer.
Validation: the new tests fail on main and pass now, including a failed outgoing save, two switches in one second, overlapping switches on one home, and a switch whose seat became ineligible or moved home while it waited. The provider-switch and route suites and the CLI tests pass, and the independent security review's real-file controls pass. Native proof with two logins in one home and a control seat is Quality's.
Claude can refuse a startup request, for example on an expired login, after OpenRig has checked readiness for the next startup input, so the next input could be pasted and submitted into the refused turn. OpenRig now checks again before that later input's Enter, using the existing refusal classifier. A visible refusal leaves the paste alone, holds the remaining inputs as owed, and reports attention required. The message says OpenRig pasted the next input but withheld Enter, so it may still be in the input box and shouldn't be resent. A fresh start keeps the fresh-launch recovery command; a resumed or forked start keeps its conversation and says to clear or send the staged text after signing in. `seat continue` can't replay the staged input.
Cost: one extra readiness observation per later Claude startup input, and manual inspection when a refusal leaves text staged; no added sleep or request-completion wait, setting or code. A refusal arriving after the final observation can still race Enter, so this isn't an atomic delivery guarantee.
Validation: an ordered no-model discriminator drives the real Claude readiness check, classifier and file delivery with simulated terminal I/O, using the retained native refusal frame. A refusal after the check, or after the paste, let the next Enter through on the old source (2 of 6 cases) and is held now. Healthy, stale-refusal and unavailable-capture controls keep their behaviour. The message correction failed four staged cases before it and passes now, and the startup-orchestrator, startup-submit-confirmation and native-resume-probe suites pass. Native invalid-login and same-home sign-in recovery remain unverified by this change.
Follow-up to #1869.
A managed seat can keep an unrelated project pane beside its recorded tmux pane. `rig send` and queue wakes now keep targeting the recorded pane instead of refusing the whole session because a sibling pane exists. The guarded write still requires the persisted bound pane to be present and writes to that immutable pane ID, not to the session's selected pane. The readers that decide whether the seat can take input read the recorded pane too: the readiness probe's terminal command and screen captures, the fresh-hook veto, the Codex foreground check (the session name is kept for identity), and the copy-mode cancel. The private unbound probe and other paths without an immutable managed pane stay single-pane strict.
Validation: a sibling-pane regression fails on main with `ambiguous-pane` and passes now; split-session tests where the recorded pane differs from the selected one cover the fresh-hook veto and the Codex foreground check, and the existing probe-context expectations now name the recorded pane. The native-claude-delivery, seat-delivery-guard and session-transport daemon tests pass, and lint covers the daemon build and typechecks.
Thanks to @Vaishnavi220506, and to @tianqizhao0704 for the precise report.
Sharing an exported team no longer silently omits its local culture file. `rig export` copies the declared file's bytes next to the YAML for a local daemon, creating declared subfolders, keeping any existing file or symlink with a check-it warning, and skipping a source or destination that would leave either folder. A missing, unsafe or remote source is reported as could-not-copy alongside the existing guidance, and the YAML export still succeeds. Export also names each seat whose agent ref leaves the folder, whether `path:`, absolute, or a relative `local:../` ref like the shipped starter team's, and says how to make it portable. It warns when the installed name remains `starter`, since a bundle's name doesn't rename the rig. The bundle guide covers archive vendoring, README inclusion and checking the culture file over localhost tunnels.
Cost: one declared culture-file copy on local export plus targeted guidance; no agent-tree copying, automatic renaming, new API or live-rig change. Remote and tunnelled exports still need the culture checked from the daemon host, and a successful export is still distinct from a portable, validated bundle. Validation: the escaping-relative-ref case fails before the fix and passes after; 31 export/import checks and the CLI typecheck pass at this head, and the first head's 148 focused checks and daemon build passed. The symlink fixture is skipped on Windows.
A pod-aware seat that has never successfully launched is no longer left at a tmux shell after a daemon restart. OpenRig persists the validated launch recipe when it materializes the seat, and when restore has no startup context and no resume request, it uses the existing validated pod launch effect to start a fresh harness. The seat's saved model, policy, effort, environment, session source, restore policy and cwd stay authoritative over the recipe's defaults. Resume stays fail-closed when there is no durable startup context. A recipe-backed fresh launch starts the provider harness and may consume provider usage; that is the intended consequence of repairing a seat that otherwise has only a provisional shell.
Limits: only pod-aware seats with a validated persisted recipe and a matching member definition fresh-launch automatically. The recipe's shape, including every member launch addition, is checked before the provisional shell is retired; legacy seats, malformed recipes and recipes whose member was removed surface attention_required with the shell and binding kept. A recipe is configuration evidence, not proof that the provider executable, credentials, workspace or external files are still available, so those checks stay in the normal launch path. The validated rig specification is stored per node so one seat can be rebuilt after a restart, a small storage cost for per-seat recovery. The new migration is 136_node_launch_recipe.
Validation: composed tests through the real restore-to-instantiator callback cover no recipe, invalid JSON, a malformed member addition and a missing member, each ending attention_required with the shell and binding kept, and the saved model, effort, Codex profile, cwd and permission policy reaching the launch input without stubbing the launch binding. Daemon build and lint pass, and the node-launch-recipe, restore-orchestrator and topology-ingress tests pass on the author's machine; six existing Windows path assertions are unrelated.
Refs #49. Thanks to @Vaishnavi220506.
A Claude seat that OpenRig compacts now gets its post-compaction turn boundary on the next poll, so its restore starts by itself. Before this, the boundary waited for whatever changed the screen next: about 70 s in a native Claude Code 2.1.309 run, when Claude's own 60 s idle notice happened to arrive, and 21 minutes on a seat whose activity hooks didn't reach the daemon, until someone typed to it.
Why it waited: right after `/compact`, every row above Claude's input box is indented compaction text and PostCompact hook output, so `findClaudeComposer` never reaches the status head within its 20-line window and the pane reads `unknown/no_activity_signal` by design. The post-compact drain is pane-only, because the newest hook can predate `/compact`, and the hook after compaction (`SessionStart` with source `compact`) reads unknown too.
The change: the managed compaction's turn-boundary send, and only that send, passes `postCompactTurnBoundary: true` beside `readinessFromPaneOnly` to the pane classifier. For that send, a complete framed empty Claude box with its mode footer reads idle when every row above it is Claude's tool or hook output, that is a `⎿` row or its continuation indented five or more columns, and none is a task-list row. That is the post-compaction screen. A working seat's streaming reply text (two columns in) or a task list pushing the live status out of view still reads unknown. Live status, a typed draft and dialogs are checked first and still refuse. Every other send, the restore and audit stages, the input-box check after an uncertain `/compact`, the typing guard and the occupant gates are unchanged.
Validation: the new file's cases fail on main's classifier and pass now, including the hidden task-list and streaming-reply screens and the real `SessionTransport.send`. 1,348 tests across the 35 daemon files that exercise the classifier, the activity probe and the compaction enforcer pass, and the daemon typecheck is clean. Replaying the native 2.1.309 run's 318 captures: all 91 stall captures go from unknown to idle, the 12 compacting captures stay active, and none changes outside the stall window. An unassisted native compaction is still to be run. Cost: source +38/-4, tests +165/-7; no new setting, refusal, code or timer.
A fresh Codex user who declined Codex's offer to remember an OpenRig command rule then approved 22 escalations one by one during setup (23 requests, one interrupted; 21 began with `rig`). The rigs skill now explains, when that offer is declined, that even routine status and screen reads may keep prompting, and offers the existing operator conversation. Only after the user explicitly chooses the operator route does the installer stop polling, and the operator follows the work through the team lead's queue task and its handoffs. The default Claude installer and a Codex installer whose user accepts the rules keep their existing relay and follow-through duties, and users who prefer the installer keep their chosen per-command approvals. The rules offer now says it applies from the next session, and both handoff routes carry the user's constraints and declined choices.
Text only: the rigs skill, its identical shipped copy and the kernel operator role. The existing optional command rule, sandbox and setup behaviour are unchanged, and general network access is not enabled as a workaround. Checks: diff check, docs guard, skills mirror check and byte equality of the two skill copies; text scenarios cover the Claude default, Codex accepting, Codex declining and choosing the operator, and Codex declining and staying. No native prompt-count improvement is claimed; a fresh-user native trial is Quality's.
Someone typing a multi-paragraph message in a Claude seat's input box that starts with a blank line could have an agent's `rig send` typed into or submitted with their unsent text: the box's empty prompt row plus its mode footer read idle without the continuation text being inspected. The classifier now reuses `composerRegion` just before its idle shortcuts: when the Claude prompt row is empty but the recognized box body has text, it returns unknown, so the typing guard and the composer's stop-and-show protect it. Codex seats are excluded, and the existing input guard, deliberate-input handling and delivery paths are unchanged.
Validation: the new fixture (a blank first prompt row, an indented draft on the next row, a framed box and the accept-edits footer) reads idle on unmodified source and unknown now; 216 tests pass across claude-warning-footer-readiness, composer-input and seat-delivery-guard. An independent base/candidate/composed matrix confirmed the change, that empty and whitespace-only boxes keep their result, that Codex's empty-placeholder box stays idle, and that it composes with the post-compaction boundary change in #1971. This repairs the tested blank-first shape, not every draft layout. Cost: 7 source lines and 7 test lines, no new refusal, setting or code.
When Codex is waiting at its initial sign-in chooser or API-key entry, startup now reports `attention_required` with `login_required` and a sign-in explanation, instead of eventually reporting a generic readiness timeout. Startup context stays pending, nothing is typed into the login screen, and `rig ps --nodes --json` shows the reason and the continuation hint through the existing startup and inventory path.
The shared probe requires a native Codex process, or the adapter's verified native descendant, and a current, complete login panel with nothing after it. It distinguishes older composer text above the panel from a newer composer below it, and an unverified wrapper is checked and recaptured before readiness or menu automation. Detection is limited to the captured complete English chooser and API-key layouts; other authentication screens keep their existing handling. Claude's refusal handling from #1417 is unchanged: its persistent "Not logged in" footer alone still doesn't prove a login blocker for gateway users.
Validation: fixtures captured from the official Codex CLI 0.164.0 in a fresh container with no credentials and networking disabled, covering the chooser and API-key screens in wide and narrow terminals and managed --no-daemon launches. The new regressions fail on the original probe; 558 focused readiness, adapter, startup and inventory tests pass. The startup regression uses the real Codex adapter with captured terminal content, real SQLite persistence, the nodes HTTP route and the CLI JSON projection, and checks the attention status, the login explanation, held startup context and zero delivered files or keystrokes. This verifies recognition and startup propagation, not a completed native login. docs/reference/help.md covers finishing the sign-in and continuing.
Addresses #6. Thanks to @Aschenbath.
When the stuck sweep closes its own finding, its transition history and `queue.updated` event now name `stuck-sweep@system` as the actor. Previously they credited the finding's source agent with that automatic action. The finding keeps its source, destination and claim, and the underlying work is unchanged. The closure query no longer reads the unused `source_session`, and the coordination architecture document explains the difference between ownership and the actor.
Addresses the closure-attribution part of #1419, following the retry-suppression fix in #1449. It covers attribution only, not every delivery or pickup evidence-policy case in that issue, and existing transition history isn't rewritten.
Validation: three regression cases failed before the fix, recording the source agent instead of the sweep, and pass after it; 183 tests pass across the stuck-sweep, human-custody, wake-ladder and pickup daemon files, covering pending and claimed findings, durable transition and event actors, unchanged ownership and source work, and no second closure on the next sweep. A posted human notice keeps the person's original ask open. Build, lint and the UI tests pass.
Thanks to @Aschenbath.
At startup the daemon checks whether another running OpenRig daemon already has the same database open. An inconclusive check used to read exactly like a clean one, so someone who turned on `daemon.db_conflict=stop` got no sign that it hadn't run. Now a check that can't get an answer adds one warning to the existing database-warnings list, which reaches `rig daemon start` and `/healthz`, with either setting: "Couldn't verify whether another daemon has database <db> open (<reasons>): ownership is unknown, not confirmed clear. Startup and database writes continue", plus, under `stop`, that it stops only on a confirmed conflict.
`lsof` now reports why it had no answer (not installed, timed out, or failed); exit status 1 stays a clean "nothing holds this". A matching record whose listener doesn't answer, times out, or gives an answer that can't be read (the body aborts, or a 200 isn't JSON) is unknown. A holder whose own files can't be read is unknown unless a record confirms it. Definite answers stay non-conflicts: a record for another PID or database, an unreadable record, and a listener answering for a different PID. The stop is unchanged: `daemon.db_conflict=stop` still stops only when an open-file holder, a matching `daemon.json` and a listener reporting that PID agree, and the confirmed-conflict path, its message and `--allow-db-conflict` are byte-for-byte as before. getting-started.md gains one sentence. Known limit: a recorded listener answering non-200 still adds no warning, as before.
Credit: Calvin Edwards (@calv-io-n) found that an inconclusive check reads as no conflict, in factory-level/plantworks#114; that change refused the start on an unknown result, and this one says so instead.
Validation: daemon-db-conflict.test.ts runs the real createDaemon to its first database open with fake lsof and fetch: missing, timed-out and failing lsof, a slow or unreadable listener answer, and an unreadable holder each warn in both modes without stopping; clean boots add no warning; the existing confirmed-conflict, go-ahead and cross-home cases are unchanged. 17/17 there and 7/7 in daemon-db-path.test.ts; the independent review's 9 controls pass. Cost: source +43/-13, tests +75/-2, docs +3; no new refusal, stop, setting or code.
When a seat waits at an approval or question prompt and its tmux pane is smaller than a standard terminal, the prompt can be cut off, so `rig capture` showed only the bottom of the menu ("1. Yes 2. No") and whoever was looking couldn't see what the seat was asking. Now `rig capture` and the MCP capture tool check whether the captured screen is at a permission or selection prompt, and read the pane's size only then. Below 80x24 the result carries `promptPane: { width, height, hint }`, and the CLI prints the hint on stderr, for example: pane 18x12 is smaller than 80x24, so the prompt may be cut off; resize it (tmux resize-window -t =<seat>: -x 120 -y 40) and capture again. The command includes the daemon's tmux server flags when it doesn't use the default server, and targets `=<seat>:`, the seat's own session's current window, so a same-named window in another session is never resized.
Other capture callers are unchanged and make no extra read: send verification, walk, the previews and attention recovery. There's no polling and no new setting, and `rig capture --help` describes the note.
Validation: the tests alone fail on main for the small-pane note, the route asking for it and the CLI stderr note, while the no-change cases pass (a normal-size pane, a shell in a small pane, a caller that doesn't ask). A real private-tmux control, with a window named like the seat in another session, failed on the first head (it resized that other window) and passes now, including for a seat name with a space and an apostrophe. 312/312 across the capture, tmux-server and session-transport daemon files and 19/19 in the CLI capture tests.
A team imported with `--materialize-only` could not start one ordinary seat: launch entered restore and reported “no startup context.” Retain each materialization’s declaration in the existing startup record, then let positional `rig launch <rig> <seat>` use the shared validated-spec launch ingress for that never-occupied seat. Resolve it with current saved settings and policy provenance, reporting changes from the declaration. Other seat records and IDs stay intact; successful first start captures context for later restore.
Cost and limits: one retained declaration per materialization, no migration or setting. Source files must remain available. Present or indeterminate targets and consequential races stop with distinct remedies; ordinary metadata edits do not refuse. Old materializations are not retrofitted. Explicit snapshot/subset launches remain restore; `--plan` remains restore selection.
Validation: the unchanged topology-ingress pin fails before the final correction and passes afterward, alongside all 17 HTTP/SQLite first-start controls. Daemon typecheck and daemon/CLI/TUI builds pass. The local full daemon run had 1,171 passing files, 39 failing and 4 skipped; after fixing missing local builds and temp/tmux environment prerequisites, all 39 failed files passed one qualification (210 checks). The latency control passed separately at 43.4 ms against its unchanged 250 ms budget. The original full run remains red; this is not a single clean full-suite pass. Fake runtime/tmux adapters cover authored startup and preservation; native provider readiness is untested.
An agent that reaches OpenRig through MCP (a desktop app, or an assistant on `rig mcp serve --http`) could claim and close an item but couldn't hand it on, so passing work along meant close-and-create, which loses the link a delegator follows. Two work tools now call the daemon's existing routes, the same ones `rig queue handoff` and `rig queue unclaim` use. `rig_queue_handoff` closes the item as handed off and opens its successor in one transaction, with lineage in `chainOfRecord`; the task's text, tags and priority carry over unless given, and a note, summary and person-route `evidenceRef` are optional. `rig_queue_unclaim` returns an in-progress claim to pending for the same seat with its reason recorded. Both are annotated destructive.
Scope for an authenticated caller matches `rig_queue_update`: the item's owner must be in scope, and a handoff's next seat too, so it never reaches a seat outside the grant or a person. A timeout says the outcome is unknown and to check `rig_queue_show` before retrying; a repeat after it landed answers `qitem_already_terminal` and creates nothing. The server instructions gain one sentence, and the openrig-user skill, its digest and the capability map's MCP sentence are updated (36 tools, 19 work tools). Not included: cross-host handoff, `targetRepo`/`gate`, `fallback`, hosted grants, the 2026-07-28 protocol and principal attribution.
Cost: source +58/-1, tests +92/-10 in 3 files (about 1.6 test lines per source line), docs +15/-9. No new refusal, error code, route, setting or dependency. Validation: 70/70 across the four MCP queue/HTTP/server/operations files and 9/9 in the other server-building MCP files; 8 of the new cases fail against main's `mcp-server.ts`. The independent security review ran 74/74, including real HTTP grants whose owner and destination scope checks each fail when removed. HTTP fixtures use in-memory SQLite and inert delivery, not native desktop-client or remote deployment proof.
An open rig view keeps a seat’s tile when its tmux session is replaced. The shared command waits up to 30 seconds for the same session name and managed node identity, then reattaches by exact tmux ID. It checks the name returned by tmux before reading identity, so a missing target cannot select a sibling. Removed or absent identity markers keep one-shot attachment. Herdr reuse and TUI/CLI copy guidance read the separate short recovery command.
Cost: one shell per tile, with metadata polling only while disconnected. A deliberate detach takes about two seconds to close; a replacement delayed beyond that live-session grace can require reopening. Close and reopen views created before this change. No watcher, setting, schema or provider-specific recovery mechanism.
Validation: real private tmux reproductions cover four-seat replacement, removed identity, a missing target during replacement, and disappearance before opening; each fails before its corresponding fix. The last two exposed the CI failure without increasing any timeout. All 458 affected terminal contract and CLI window checks plus the daemon typecheck pass locally at this revision. Earlier round checks also covered TUI rendering and CLI/TUI typechecks; those were not repeated for this shell-only correction. Tests use the actual stop adapter/composer because the scenario runner has no terminal-view action. Native Herdr/cmux/Rex apps and SSH were not run.
Addresses #1939. Native Herdr, cmux and Rex and real SSH were not run, so the issue stays open for the reporter to confirm.
## What this changes
Two comments, and no logic. They now say how much of the quoted-prompt veto's (#1955) premise has been observed natively.
The veto treats prompt-looking text above Claude's empty input box as quoted output, but only when Claude's own timer row runs directly above that box. That rests on one fact about Claude's screen: a real dialogue replaces the input box, rather than being drawn beside the running timer row.
## Why
#1955's comments stated that fact outright and pointed to "the native captures here". One of those fixtures, `claude-permission-2.1.300`, holds transcribed menu excerpts rather than full screens (its `.md` says so), so it can't show where a dialogue sits. #1955's PR body was corrected at the time, with the claim labelled an inference until a native check.
## What the native check showed
The check ran on Claude Code 2.1.309, on #1955's fix and on a build without it:
- **Protected dialogues:** the MCP enable menu, a Read permission dialogue and an AskUserQuestion picker each replaced the input box, and each refused a send.
- **Parallel calls:** this included a turn with two Read calls issued in parallel. Twenty sampled frames show the waiting dialogue, and none shows it beside a hook timer row.
- **Fix versus no fix:** quoted selector text, and a permission phrase in a running Bash command, refused a send and a queue wake without the fix, and were delivered with it. That held in the fresh running-hook window too.
**Not covered:** other Claude versions, macOS, Codex (not run), and any layout the run didn't exercise. The comments say the observation covers the exercised layouts, not every version.
## The changes
- **`session-transport.ts`, the timer-row comment in `classifyPaneActivity`:**
- states the dialogue geometry as natively observed on 2.1.309, including parallel Read calls;
- scopes it to the layouts exercised.
- **`claude-quoted-prompt-veto.test.ts`, its header:**
- the same statement;
- names which fixture shows geometry: the 2.1.289 picker is a native screen. The 2.1.300 permission menus are excerpts, so they test only the menu text.
Comment lines only: +12/-6. No code, test assertion, matcher, refusal or setting changes.
## What a user gets
An agent that sees `stale`, or two copies of one skill with different hashes, in `rig skill audit` learns what that means before it refreshes anything:
- **What the audit is:** a date and hash finding, not a judgment of whether the copy is safe to refresh.
- **What answers "can I refresh this?":** the existing read-only check, `rig skill loadout` without `--apply`, and what each of its answers means.
It also learns where nothing can answer: a global or untracked copy has no install record. So it doesn't overwrite a copy whose history is unknown, and doesn't treat every difference as a deliberate fork.
## The change (text only)
**`rig skill audit --help`** now says:
- **What the audit reports:**
- declared provenance;
- whether a verified date is within 90 days and names an evidence source (`stale_verified` and `bare_verified`, which are state `stale` in `--json`);
- the content hash;
- whether a copy is byte-identical to a bundled plugin skill.
- **What it doesn't:** whether a copy is safe to refresh.
- **For a managed copy in a working folder, the read-only check:** `rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json`, without `--apply`. Its answers:
- `current`;
- `stale`: still OpenRig's last projection, so `--apply` can refresh it;
- `conflicting`: read the detail. It can also mean the target couldn't be inspected, so any difference is unknown, and a confirmed difference doesn't prove a deliberate edit;
- `shadowed`: read the detail;
- `missing`.
- **`rig skill loadout --seat <session|self> --json`** (0.7.1 and later) lists a seat's own skills and where they're stored, without classifying them.
- **A global or untracked copy:** its origin stays unknown. Ask the skill's owner, and don't move global skills into a project just to get a record.
**The audit's human output** gains one closing line, on PASS and FAIL: the audit doesn't say whether a refresh is safe, plus the read-only `loadout` command and a pointer to `--help`.
**`docs/reference/project-workspace.md`:** one paragraph in its managed-skills section says the same.
No new check, refresh, classification or record.
## Checked against the source
- **`packages/daemon/src/domain/skill-audit.ts`:**
- **freshness:** the 90-day window (`FRESHNESS_WINDOW_DAYS`); `stale` is set for `stale_verified` or `bare_verified`.
- **content hash:** `hashSkillFolder` hashes the folder's top-level files.
- **bundled match:** requires the plugin's exact file set and bytes.
- **refresh state:** the audit reads no ownership record and classifies none.
- **`packages/daemon/src/domain/skill-catalog.ts`, `classifySkillProjectionTarget`:**
- **`current` / `shadowed`:** an equal digest, owned or not;
- **`stale`:** an owned target still at the prior projection, "can be refreshed safely";
- **`conflicting`:** a different digest, and also the `catch` when the target can't be inspected;
- **`shadowed` also covers a kept copy:** a same-named unowned skill, or an owned plugin copy edited after projection, as its detail says.
- **The read-only `--seat` inspection** (`inspectSeatSkills` in `seat-skill-apply.ts`) lists skills, storage and delivery without per-copy statuses. Those come only with `--apply`.
- **`--seat` is first in 0.7.1:** it's in the 0.7.1 line and not in 0.7.0's `skill.ts`.
## Proof
- **RED:** the two new cases in `packages/cli/test/skill.test.ts` fail on main's `skill.ts`.
- **GREEN:** the file passes, 6/6.
- **Not run locally:** the CLI typecheck, which CI covers.
## Cost
- **Source:** +21 lines in one file, help text plus one output line.
- **Tests:** +21.
- **Docs:** +18.
- No new option, setting or refusal.
## What a user gets
A managed Claude restore reads the generic `post-compact-extra.md` without stopping at a Read approval. The same holds when the compaction
settings move the extra instruction file or the restore maps somewhere else, when the configured file is a symlink, and when the setting
has stray spaces around it.
## Why it happened
#1940 grants a seat's restore reads as `Read(//…)` allow rules. It covered the per-seat `<home>/compaction/post-compact-extra/<seat>.md`.
When a seat has no per-seat file, the restore request names the generic `<home>/compaction/post-compact-extra.md` instead (the default
`policies.claude_compaction.message_file_path`), and nothing granted it. A managed restore on a Claude seat with OpenRig's core plugin
stopped there at an approval; one approval let it finish.
The two compaction settings that move these files had the same gap:
- `message_file_path`, when set to another file;
- `preparation_root`, where restore maps go when it's set.
## The fix
- **The generic file is always granted.** The restore request names it even before it exists, so whether it exists at launch says
nothing about the restore.
- **The configured extra file and `<preparation_root>/<seat>/*/RESTORE-MAP.md` are granted too.** Both Claude launch paths (fresh or
fork, and resume) take an optional `restoreReadPolicy`. The daemon resolves it at each launch from the same compaction settings the
restore uses. Without it, the grants are as before plus the generic file.
- **The configured file is granted as the restore request names it.**
- It goes through the same trim as the restore's resolver, one shared helper, so a padded setting grants the file the request names.
- The whole file, not only its directory, is resolved, so a configured file that is itself a symlink has its target granted too.
- The grant is still that one file at its two spellings.
- **Every rule is at both spellings,** the resolved path and the real path, as in #1952.
- **Still not granted:** the session transcript, which the restore request names only when both the restore marker and a restore map are
missing.
- **Not covered: a relative `message_file_path`.** The restore request names it as written, relative, while the grant resolves it under
the daemon's working directory, which can differ from the seat's. Such a seat still gets an approval, so it fails safe. An absolute-or-empty
check like `preparation_root`'s would close it, separately.
- **A test pin:** `s03-bound-launch`'s check of `startup.ts` text now expects the new constructor line.
Source: +42/-8 in five files (`managed-restore-read-access.ts`, `claude-compaction-enforcer.ts`, both Claude adapters, `startup.ts`).
## Tests
- **New:** the generic file, a configured extra file and a configured preparation root are each granted at both spellings, and nothing
else in those directories or another seat's maps is; both Claude launch paths grant the configured extra file the restore will name.
- **A parity test** renders the restore request the enforcer actually sends, and checks every file it names against the granted rules,
where it's named and where it resolves. It runs for the default settings, a configured extra file and preparation root, a configured
file that is a symlink, and a padded setting. So a restore file added later without a grant fails here, not at a native approval.
- **#1940's exact-list cases** now expect the generic file.
RED, the tests alone on main (`c061d705`, run 40151781330): exactly 8 tests fail, all in `managed-restore-read-access.test.ts`. They are
the six launch-shape exact-list cases and the two new tests. Nothing else fails.
GREEN at `f6566d9b` (run 40154339522): all 8 jobs pass.
Round 1 RED, the parity test alone on `f6566d9b` (`de3ebe30`, run 40157449038): exactly the symlinked-file and padded-setting cases
fail; the default and configured cases pass, and nothing else fails.
Round 1 GREEN at `4f6b804b` (run 40157573273): all 8 jobs pass.
The managed restore case that stopped at the approval still needs a native rerun after merge.
The parked-owner watchdog reminds a seat that is idle at its prompt while it holds open queue rows. It ran on its next 120 s tick, so a seat that had just handed work to a peer, or just asked a person a question, got "You are parked …" within a couple of minutes of ending its turn. It then spent a turn on the reminder, or re-asked the person.
**What changes**
- The parked diagnosis reports `activity.since`: when the seat's arbitrated state last changed (its activity, or its needs-input count, reason or evidence rung). For a stopped seat, that's when its current waiting state began.
- The parked-owner consumer skips a seat whose waiting state has been unchanged for less than 5 minutes, recorded as `waiting-grace` in the evaluation notes. A seat still idle with its rows after that is reminded on the next watchdog tick, exactly as before. If the start isn't known, the reminder goes as before.
- A seat at an approval or question prompt gets no grace. The consumer's refused attempt there is what starts the prompt-blocked alert to whoever can clear the prompt, so that alert is not delayed.
- The reminder now tells a seat waiting on a person's answer not to ask again, and to hold the row with `rig queue block <id> --on <who> --wake-after <duration>` (a hold on a person also needs `--summary` and `--evidence-ref` unless the row has them).
- A held row with a live wake still doesn't count as parked, so that hold keeps a long wait quiet.
- There's no new setting. Both cases in the report end the seat's turn, so one default covers them.
- `docs/reference/seat-delivery.md` describes the grace and the prompt exception.
**Tests**
- RED, the first tests alone against main: 3 failures.
- a seat that started waiting 30 s ago is reminded on that tick;
- the grace doesn't reach the consumer through the shipped diagnosis;
- the diagnosis has no `since`.
The cases that keep today's behaviour pass on main too: the reminder after the grace, and the reminder when the start is unknown.
- RED, the prompt test against the first fix: a seat stopped at a prompt 30 s ago was skipped instead of attempted (1 of 37).
- GREEN: 247 of 247 across the 11 daemon files that build the parked consumer or call the diagnosis.
Addresses #1933.
## What a user gets
Some Codex users keep their hooks in `hooks.json`. Before this change, OpenRig always added its activity hooks as an inline block in
`config.toml`, so their Codex home mixed both formats and Codex warned about it at startup. Now, when a Codex home has a `hooks.json`,
OpenRig puts its activity hooks there and moves its own old inline block out. A Codex home without a `hooks.json` gets exactly what it got
before.
Your own hooks, their order, their trust and your disables stay as they were. This covers OpenRig's own block only: if you keep your own
inline hooks in `config.toml` beside `hooks.json`, they stay where they are, and Codex may still warn about that mix.
## How it works
- **OpenRig's groups:** one group per activity event (`SessionStart`, `UserPromptSubmit`, `Stop`, `PermissionRequest`), marked with
`statusMessage: "OpenRig activity"`, which is also what Codex shows while the hook runs.
- An existing OpenRig group is replaced in place.
- Otherwise the group is appended after your groups, so your groups keep their positions, and Codex's trust is keyed by position.
- **Recognising OpenRig's group:** only its exact authored shape counts. That's one handler with only OpenRig's fields, its timeout and its
label, running the activity relay from where OpenRig ships it (this install or an earlier one). A group of yours that runs your own
script, even one with the same file name and label, is left alone, and OpenRig adds its own.
- **Trust:** written only for OpenRig's groups, keyed by the real path of `hooks.json`. Your groups are never trusted on your behalf.
- **OpenRig's old inline block** in `config.toml` moves out. If you had disabled one of its hooks in Codex (`enabled = false`), the disable
moves to the hook's new `hooks.json` key.
- **Turning hooks off** (`runtime.codex.hooks_enabled: false`) removes only OpenRig's groups. Codex keys trust and disables by position, so
when a removed group sat before yours, your later groups' trust and disables move with them to their new positions.
- **Both files are checked before anything is written.** The proposed `config.toml` must parse and equal the current one plus exactly the
planned fields. Otherwise neither file changes, and a warning says so. The same applies to a `hooks.json` that can't be read as hooks.
- **Per-seat homes:** a seat with its own `CODEX_HOME` uses that home's `hooks.json`.
## Credit
fsafey/openrig#7 found this problem and handled more cases. The disable migration and the trust re-keying on removal here follow the same
idea. Not carried: a warning when your own inline hooks remain beside `hooks.json`. Your hooks are left where they are.
## Tests
`codex-activity-hooks-json.test.ts` covers:
- OpenRig's groups added after yours, and trust for exactly them;
- the old inline block moved out, while your inline hooks stay;
- idempotence, and a new relay path replacing OpenRig's group in place;
- a malformed `hooks.json` left alone;
- removal;
- a seat's own `CODEX_HOME`;
- no `hooks.json`, which gives today's block.
**Round 1 adds:**
- a disabled inline hook moving disabled, with the enabled case as a control;
- removing OpenRig's group from between two of yours, so the later one keeps its trust and disable;
- a quoted `"trusted_hash"` that would have become a duplicate key, which now leaves both files untouched;
- your own relay with OpenRig's file name and label, which is kept, and OpenRig's group is added after it.
Round 1 RED, the tests alone (`7a6b2e81` on `9f4cbe98`, run 40151455871): exactly the four new tests above fail; the enabled control
passes, and nothing else fails.
Round 1 GREEN at `30069c00` (run 40155470675): all 8 jobs pass. The first fix head, `50550a57` (run 40151457584), failed two removal
tests. Its check before writing compared a planned `config.toml` holding an empty `hooks.state` table against text that had dropped the
table with its last entry, so it refused and left OpenRig's groups in place. `30069c00` compares the two without an empty table, which
means the same as none.
Thanks to @fsafey, whose PR in their fork found the mixed-format problem and showed where trust is keyed.
## What a user gets
A queue wake refused at an approval prompt retries once when observed attention clears to work or idle, instead of waiting for the stuck sweep. Hook updates and changes in existing pane captures use the same activity resolver as status; the prompt refusal and all delivery checks remain.
## How you verified it
Baseline: three missed-retry assertions fail; the existing sweep control passes. With the change: 114 checks pass across focused queue, outbox, structural activity, wake-ladder and startup suites, including a real hook update through the assembled queue and a split correlated return whose uncertain sibling stays outside the clear retry. General recovery of that sibling remains covered. Daemon typecheck passes. No native model run; tests use real SQLite and domain code with simulated transport results and pane frames. Required CI is pending.
## Anything you were unsure about
Only pending prompt-refused wakes qualify. Existing outbox claims preserve the frozen notification and exclude delivered, uncertain, retained or active sends; legacy nudges retain their existing path. Unknown observations do not clear attention, and a retry refused again stops until another observed clear or the existing backstop. No new timer, schema, reason code, or extra capture. Cost: +51 net source lines, +144 test lines (2.67 added test lines per added source line), a small session set and one destination-scoped recovery pass per observed clear. A clear the daemon never observes still relies on the sweep.
## What a user gets
In a Slack channel where many agents post, the person sees who is asking before the ask. Every gateway post now starts with its sender, `From <seat> on <machine>`:
- as the first block, above the headline;
- as the first line of the fallback text a notification shows.
Before, it was the last block and the last line.
A wake-rung escalation (the queue's last rung, which posts a stuck row to a person) now says which seat the work is waiting on: `Waiting on <seat>: <the row's summary>`. Before, it showed only the row's summary, often with no seat named at all.
## The change
- **`slack/message.ts`:**
- the sender line moves to the front, in both the blocks and the fallback text, and now reads `From …`;
- it's still named once, with the same caps and the same fallback budget;
- at Slack's 50-block limit the repeat note is still the block left out. It's now found by identity, since it's no longer the second block.
- **`operator-delivery-engine.ts`:**
- the escalation's summary names the seat the row is waiting on;
- its sender is unchanged: the row's own. So a reply in the thread still goes where the row's posts go;
- a row with no sender is still signed with the queue's place. The stuck seat didn't send the escalation, so it isn't shown as the sender.
- **Not in this change:** a separate "what you need to do" line. No row field holds a one-line ask (`humanIntent` is `decision` or `update`, and `humanDetail` is free-form), and this change adds no parsing.
## Tests
- **New** (`slack-sender-first.test.ts`):
- the sender is first, and named once, for a full session, for a session plus its machine, and with no seat;
- the 50-block limit drops the repeat note, never the sender or the ask;
- the escalation headline names the waiting seat, with and without a row sender, while the sender stays the row's own.
- **Fail-first:** all 6 new tests fail on main.
- **Existing tests that pinned the old order** (the sender last, the body at block 1, a lowercase `from`) are updated to the new order in five files.
- **After the change:** all 80 Slack, gateway and delivery test files pass (1,186 tests), and daemon `tsc --noEmit` is clean.
## Docs
`docs/reference/slack-app-setup.md` now says each post starts with its sender.
## What a person gets
Take a row that was claimed, parked (blocked) for an hour, then resumed with `rig queue update --state in-progress`. If it then went quiet, the stuck sweep could report "stalled-after-claim on <row> (77 min)". In fact it had been active for about 7 minutes, and the finding's own "why" line said so: "no meaningful queue change for 7 min".
Now the headline and the body's `age:` line count from the same moment as the stall decision, and round the same way, so all three agree to the minute.
## Why it happened
Line numbers are at `c3c514f2` unless marked "this head".
- **Which clock the age used:**
- A stalled candidate's age came from the claim time: `ageMinutes: minutesSince(row.claimedAt, now)` (`queue-stuck-sweep.ts:467`).
- That age is printed in the summary (`:743-745`) and in the body's `age:` line (`:400-406`).
- **Why that clock included the park:**
- `claimed_at` is written only on claim (`queue-repository.ts:2570`, `:2579`).
- The generic state update that resumes a parked row (`:3165-3173`) doesn't touch it, so the claim time still counts the parked hour.
- **What the decision already used:**
- The stall decision itself anchors on the latest of claim, last meaningful change and post-claim heartbeat (`queue-pickup.ts:98`), and its evidence floors the minutes (`:101`).
- `minutesSince` rounds instead (`queue-stuck-sweep.ts:308-312`). So the headline could also differ from the "why" by a minute even without a park.
## The change
- **The age:** for stalled-after-claim candidates only, `ageMinutes` now comes from the candidate's existing `evidenceAt`, which is the same anchor as the decision. It's floored by a small `wholeMinutesSince` helper (`queue-stuck-sweep.ts:316`, this head), and assigned at `:478` (this head).
- **What doesn't change:**
- which rows are judged stalled;
- `evidenceAt` / dedup / recurrence;
- routing and `claimed_at`;
- every other finding kind's age.
- **Two regression tests** in `queue-stuck-sweep.test.ts`:
- claimed → parked → resumed → quiet: the summary and body count minutes since the resume, not since the claim;
- a 30 min 40 s stall reads 30 in the headline, the body and the "why" alike.
- **Limit:** a finding that's already open keeps its stored text. Only findings created after this change get the corrected age.
## Overlap
#1313 ("distinguish input waits from stalled claims") edits `queue-pickup.ts` and `queue-stuck-sweep.ts` too, but not this age. This change touches only the sweep's helper and that one assignment, so whichever lands second rebases.
## Checked
- **Tests:**
- With the fix: all 16 test files on the sweep and pickup paths pass on this head, 373 tests.
- Without the fix: against the old `queue-stuck-sweep.ts`, exactly the two new tests fail (102 pass).
- **Review:** an independent review found nothing to change. It confirmed `evidenceAt` is the same anchor as the decision, and that the age stays a display field read only after the stall decision. It ran `queue-stuck-sweep.test.ts` on an export of this exact head (104/104).
- **Merge:** it merges cleanly with current `main`.
## Cost
One helper, one assignment, two tests (+48/−4). Display only.
## What a person gets
`rig seat restart --keep-conversation` restarts a Codex seat in place. When that restart can't prove the resumed conversation, the result used to end with this:
> The original Codex exited, but the resume was not proven: Codex resume could not be proven within the resume window: … The session was preserved; nothing was restarted.
The last sentence was false: the pane had restarted. It now reads:
> The pane restarted: the original Codex exited and the resume started in the same pane, but it wasn't proven: Codex resume could not be proven within the resume window: <reason>. Check the pane with rig capture <seat>.
- **Same fix, second case:** an in-place restart whose records name a different conversation (disproved, not just unproven) gets the same correction.
- **`account.startupGate`** no longer carries the false sentence either.
- **Other paths, unchanged:** every other caller of the adapter, where nothing was restarted, keeps its existing wording, which is true there.
This follows up #1950, where both readers noted the sentence. #1950 fixed one cause of an unproven resume, the home lookup; this corrects the message for every other cause.
## The change
Wording only. There's no change to proof logic, timing, launch, recovery or outcomes.
- `codex-runtime-adapter.ts`: the unproven and disproved results also carry the detail without the closing sentence, in a new optional `resumeDetail`. The existing `error` text is unchanged.
- `runtime-adapter.ts`: that optional field, with a doc comment.
- `seat-restart-in-place.ts`: the in-place `incomplete()` result uses `resumeDetail` and says the pane restarted. `remainingStep` is unchanged.
- `seat-restart-in-place.test.ts`: two cases, unproven and disproved, driven through the adapter and the restart service. The result says the pane restarted and never says "nothing was restarted", and the adapter-level error keeps its existing text for the other callers.
**Launch and resume is an artery** (`docs/as-built/arteries.md`). Only text changed: nothing that decides proof, timing or recovery reads the new field.
## Checked
- **Tests:** the three test files that cover these messages pass in full on this head: `seat-restart-in-place` 106/106, `provider-sign-in-fact` 30/30, `codex-rebuild-permissions` 13/13. Against the old source, exactly the two new cases fail and the other 104 pass.
- **Review:** an independent review found nothing to change. It checked:
- that both callers of the in-place result come after the original process was stopped and the resume started in the same pane;
- that earlier stop and respawn failures keep their existing paths;
- that the new field has no consumer that makes a decision.
It also ran the in-place file on an export of this exact head (106/106).
- **Merge:** it merges cleanly with current `main`.
- **Not done:** no live seat was restarted for this.
## Cost
Message text, one optional field, and two tests (+38/−5).
## What a person gets
`rig queue update --help` lists `canceled` both as a `--state` value and as a `--closure-reason` value. So the natural way to cancel a row looks like this:
```
rig queue update <id> --state canceled --closure-reason canceled
```
The daemon refuses that with `closure_fields_not_admitted`, and until now nothing said what to type instead. Now:
- **The help says it.** The description gains "To cancel a row: --state done --closure-reason canceled." The `--closure-reason` text now reads "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel."
- **So does the refusal.** The `closure_fields_not_admitted` message ends with "To cancel it: --state done --closure-reason canceled." The error code, the HTTP 400 and the CLI's exit code 1 are unchanged.
## The change
Text only. There's no change to which forms are accepted.
- `packages/cli/src/commands/queue.ts`: the `queue update` description and the `--closure-reason` help.
- `packages/daemon/src/domain/queue-repository.ts`: one sentence added to the refusal message.
- Tests:
- `packages/cli/test/queue.test.ts`: the help says how to cancel a row;
- `packages/daemon/test/sweep-a-closure-coherence.test.ts`: the refused form's message names the working form.
The wording is deliberately narrower than "closure fields go only with `--state done`". That wouldn't be true: parked rows, handoffs and supersessions also carry closure fields.
## Checked
- **Tests:**
- With the fix: both changed test files pass in full (daemon 7/7, CLI 165/165).
- Without the fix: both new tests fail against the old source.
- The built CLI's `--help` shows the new text.
- **Review:** an independent review found nothing to change. It checked the wording against the closure validator and the existing plain-cancel and supersession tests, and ran both new tests.
- **Merge:** it merges cleanly with current `main`.
- **Credit:** reported by the QA lead and a team lead, who both hit the refused form while closing rows during a release check.
## Cost
Help and error text only, plus two tests.
## What a user gets
A send or queue wake to a Claude seat that is plainly working is no longer refused as "at an approval prompt" just because the seat quoted approval-prompt text in its own output, such as a selector like `› 1. Yes, proceed (y)` or "Do you want to proceed?".
Real approval dialogues stay protected: the permission dialogue, AskUserQuestion, and the MCP server menu.
## The defect
`classifyPaneActivity` looks for prompt signatures in the last 12 non-blank lines: numbered selectors, plus phrases such as "Do you want to proceed?". Quoted text matches as well as a real dialogue does.
- **The ordinary pane probe:** a quoted selector reads as `selection_prompt`. This is pre-existing and was observed on a working seat.
- **#1864's fresh running-hook path:** this path narrows selectors to the current dialogue, but still scans the permission phrases across the whole window, so a quoted phrase reads as `permission_prompt`. This was found by reading the source and hasn't been seen on a real seat.
Either way the send is refused with `target_needs_input`, and delivery stalls until someone notices.
## The fix
One condition in the existing detector, `session-transport.ts` +9/-2.
- **When it applies:** for a Claude read, when Claude's own timer row (for example `✶ Churning… (12s · …)`, including the `running … hook` form) runs directly above its complete, empty input box. The box must be framed, with a recognised mode bar.
- **What it does:** the turn is running and no dialogue is open, so the prompt scan ignores the text above that box.
- **Only the timer row counts.** A selector-shaped "mid-work" line doesn't, so a prompt whose selector sits just above the box isn't mistaken for work.
- **What stays protected:**
- A dialogue that replaces the input box never meets the condition, because no empty input box is at the bottom. The native AskUserQuestion capture (`claude-question-picker-2.1.289.json`) shows that layout.
- **Correction (provenance):** an earlier version of this body cited the 2.1.300 permission fixture as showing this. It doesn't establish screen geometry: `claude-permission-2.1.300.md` says those are ConPTY menu excerpts with separators removed, not full-screen captures. No retained full-screen native capture shows where a Claude permission dialogue sits, or one beside a running hook timer.
- **So this is an inference [I]:** "a real permission dialogue never shows beside Claude's running timer row above an empty input box". That includes parallel tool calls, where one tool's hook timer runs while another asks permission. It's for Quality's native check. The latest PermissionRequest hook still overrides first (`classifySendReadiness`).
- A prompt drawn above the input box (`send-prompt-guard`'s FORWARD-FIX layout) has no timer row, so it's still refused.
- **Unchanged:** Codex, other runtimes, and reads without a runtime (the display surfaces).
## Limits, deliberately conservative
These still read as attention, as today:
- **an idle Claude seat** (no timer row) with quoted prompt text above its input box. Nothing on screen tells that apart from a prompt drawn above the box;
- **a draft in the input box;**
- **a mode bar outside the recognised list,** such as the default "⏸ manual mode on";
- **an unframed input box.**
## Tests
`packages/daemon/test/claude-quoted-prompt-veto.test.ts`, +120.
**The classifier:**
- quoted text reads as working with a timer row, in both modes;
- the native permission (Bash, Edit, WebFetch), AskUserQuestion and MCP dialogues, and a prompt above the input box with a listed phrase, all read as attention under every option set;
- with no runtime, a Codex runtime, no timer row, an unrecognised or non-mode bar, or a draft, the result is identical to before.
**The real send path,** `SessionTransport.send`:
- delivered for the working seat with quoted text, through both the ordinary probe and the fresh running-hook path;
- refused at a real permission dialogue and at the MCP menu, through both paths.
**Results on Build:**
- **Before the fix,** the new tests against the base source fail 3 of 12, on exactly the working-seat cases. The ordinary probe refuses on the quoted `› 1. Yes, proceed (y)` (`selection_prompt`), and the fresh-hook path on the quoted "Do you want to proceed?" (`permission_prompt`). The protection cases pass on both sides.
- **With the fix,** 1,723 tests pass across 40 files: every test file that refers to the classifier, the send probe or `target_needs_input`. That includes `send-prompt-guard` (FORWARD-FIX 1), `claude-question-picker` and `session-transport`. The daemon builds, and its typecheck exits 0.
## Cost
Source +9/-2, tests +120. No new refusal, matcher, error code or setting.
## What a user gets
Managed Claude compaction shows the owed restore stage and its last delivery result in `rig compact --state` and seat status. Active-work idle waits show as waiting, without marking the stage held or raising a failure notice. Repeated actual failures create one queue item for the delegation owner or registered human fallback; changed reasons update it without another wake.
## How you verified it
Baseline RED exposed missing diagnostics; round-one RED reproduced false busy-stage failure counts and a notice callback running inside the real lifecycle lease. After correction, 85 focused daemon tests pass, including three busy-stage polls, unresolved/rejected callbacks, retry, restart, cancellation and notification deduplication. Daemon typecheck passes. Initial author checks also passed 210 daemon and 63 CLI tests. The stub runtime cannot perform native Claude compaction; these fixtures do not establish the original native stall's cause. Required CI will run on the new head.
## Anything you were unsure about
Notices start after the guarded operation settles and do not delay its drain. The human fallback is a plain ask: urgent-only subscribers are not paged; every-ask subscribers keep their usual notification. Cost: one small record write per unsuccessful poll and at most one diagnostic item per attempt (+124 net source lines, +149 net test lines; 1.09 added test lines per added source line). Existing owner-closed dispositions stand; an open notice is not automatically closed when the stage later progresses. No new reason codes, scheduler or delivery permission; idle, occupant, typing, retry and cancel behavior stays.
## What a user gets
A managed Claude restore on a Mac reads its own restore files without stopping at a Read approval, the same as on Linux. The same applies on
any machine where the OpenRig home or the seat's project is reached through a symlink. One gap remains on every platform and is fixed separately: the generic `post-compact-extra.md` fallback isn't in the grant yet, so a restore that reads it still asks once.
## Why it happened
#1940 grants a seat's restore reads as `Read(//…)` allow rules built with `path.resolve`, which keeps symlinks. Claude Code's
[permission docs](https://code.claude.com/docs/en/permissions) say allow rules "apply only when both the requested path and the file it
resolves to match". On macOS, `/tmp` is `/private/tmp`. So a read of the compaction packet under `/tmp/claude-compaction-restore/…`
resolves to `/private/tmp/…`, which no rule covered, and the restore stopped at the approval. #1940's native A/B ran on Linux, where `/tmp`
isn't a link, so it couldn't show this.
## The fix
Each base directory (the OpenRig home, the seat's working directory and the packet root) now gives two spellings, and every restore file
is granted at both:
- the resolved path, as before;
- the real path, with symlinks followed.
A directory that doesn't exist yet, such as the packet root before the first compaction, keeps its remaining path under its nearest real
parent. Where nothing on the way is a link, the two spellings are equal and the rules are exactly as before. The grants still cover only
the seat's own files. `managed-restore-read-access.ts`: +34/-10.
## Tests
- **New:** a symlinked home, project and packet root (the packet root not yet existing) are each granted at both spellings, and every
grant stays inside the fixture.
- **#1940's exact-list test** now expects both spellings. On a Mac runner that includes `/private/tmp/…`.
RED, the tests alone on main (`69a2eb76`, run 40141826010): on the macOS runner, #1940's six launch-shape cases fail because nothing is
granted under `/private/tmp` (the bug itself), and the new symlink test fails too. Nothing else fails.
GREEN at `12d84fa2` (run 40141827784):
- attempt 1: every job passed except one daemon test file, `rex-transport.test.ts`'s timing case, unrelated, which also flaked on another
branch tonight; all the restore-read tests passed;
- attempt 2: all 8 jobs pass.
A native check on a Mac seat is still needed.
## What this fixes
`rex-transport.test.ts` › "ends a run that does not answer in time, with its process group" failed intermittently on macOS CI runners (for example run 40141827784, attempt 1). The failing assertion was the last one: an instant check that the script's backgrounded `sleep` was gone (`expect(() => process.kill(child, 0)).toThrow()`). The deadline and timing assertions before it passed.
`stopGroup` returns once the run's process group is empty and the direct child is reaped. The orphaned `sleep` is reaped by the system on its own schedule, so on a loaded runner it can still be listed for a moment after the run settles. An empty pid file would also have read as pid 0, and `process.kill(0, 0)` signals the test's own process group.
## The change (test only)
- **The orphan check:** wait up to five seconds for it to go (`expect.poll`), instead of reading the process table at one instant.
- **The pid:** assert the pid read from the file is a real one (an integer above 1).
- **The deadline:** one second instead of 300 ms, so the script's start fits well inside it on a loaded runner.
- **The elapsed bound:** under two seconds, which is the deadline plus `stopGroup`'s one-second grace. It still fails if the deadline stops ending the group at once.
No product code changes.
## Proof (local, macOS)
- **The test alone:** 8 of 8 repeated runs pass, and the whole file passes 24 of 24.
- **Without the immediate group kill at the deadline:** the test fails (2,050 ms against the 2,000 ms bound).
- **Without the deadline:** the test fails (it times out at 20 s).
## What a user gets
Proof-source monitoring recursively watched the workspace parent, including unrelated checkouts and build trees. It now recurses at the configured missions root and uses filtered shallow watches for other policy and evidence inputs. Related to #1813.
Replaced roots and input parents get rebound. Failed or unsettled coverage reports unavailable, including a missions alias resolving outside its project. Canonical readers, default behavior and judgment publication checks are retained.
## How you verified it
At 424337a7, implementation and independent QA each passed all 52 tests across proof-source-boundary, proof-source-root, proof-policy-reuse and proof-judgments. Daemon typecheck passed. Added regressions failed on their preceding implementations, including both outside-root cases before the final correction.
The checks exercise error latching, named/null replacement events, new missions after rebind, bounded reconciliation, state-change notifications, containment and publication/recovery controls. Replacement and alias callbacks use mocked delivery over real temporary files. Full repository build, test and lint were not run.
## Anything you were unsure about
Unavailable means uncertified live coverage, not filesystem isolation: a watch requested at a configured symlink can still follow its real target. Large missions trees remain recursive. Native large-tree behavior, performance and the reported freezes have not been reproduced by these checks. Startup-lock behavior remains a separate investigation.
- [x] One concern per PR; no version bump; no CHANGELOG.md edit
- [x] Tests added or updated where the change is testable
- [x] Checks run, results and omitted checks listed
## What a user gets
`rig seat restart --keep-conversation` no longer reports that the resume wasn't proven, and that nothing restarted, for a Codex seat that
did restart and resume. This happened whenever the seat's Codex ran on a `CODEX_HOME` the restart's proof didn't look in.
## Why it happened
The in-place restart reads the running Codex's `CODEX_HOME` from the process itself, and refuses to relaunch into any other home. But its
resume proof (`verifyResumeInPane`, `sendResumeCommand`) passed the adapter only the seat's node. The adapter then read the home the seat
declares in `env:` (#1937), and otherwise the daemon's. A Codex whose home came from anywhere else wrote its records where the proof never
looked:
- a home from a one-off `--env`;
- a home set on the tmux session or in the shell;
- on a build without #1937, any `env:` home.
So the proof found no record, and the report said "not proven ... nothing was restarted", although the pane had restarted and resumed.
## The fix
The restart passes the home it already read to the proof. `verifyResumeInPane` and `sendResumeCommand` take it as an optional last argument,
and when it's given it wins over both the declared home and the daemon's. Callers that don't pass it behave exactly as before. Source: +14/-11
in two files.
## Tests
- **`seat-restart-in-place.test.ts`:** in both pane shapes, shell-backed and direct, the restart passes the running Codex's home to the
proof, though the seat declares none.
- **`codex-seat-home.test.ts`:** an explicit home wins over the seat's declared home and over the daemon's, for both methods.
RED, the tests alone on main (`b15a00ad`, run 40140393992): exactly the two new tests fail. GREEN at `a66263fb` (run 40140395642): all 8
jobs pass.
## What a user gets
Upgrading OpenRig no longer stops a working Codex seat at "Hooks need review" for OpenRig's own unchanged plugin. Codex 0.164 fingerprints the whole plugin, and this release changed openrig-core's scripts and skills while its six hook declarations stayed the same, so every upgraded Codex seat stopped. OpenRig now re-approves its own plugin on upgrade, without asking, when three things hold:
- the earlier approval exactly matches the previously shipped plugin;
- nothing is disabled;
- the installed files exactly match what shipped.
## The change
- **The renewal:** a new `codex-owned-plugin-trust.ts` plans it, and only when all three conditions hold.
- "Previous" is OpenRig's recorded approval, else the pinned published 0.1.17.
- Disables and choices in any config layer or profile keep native review.
- It writes only `trusted_content_hash` under the six OpenRig-owned hook headers, plus one small receipt (mode 0600) per Codex home.
- **Checked before writing:** the proposed config is parsed and must equal the original plus exactly the planned fields. Otherwise nothing is written, no receipt is recorded and native review stays. This covers quoted keys and table text inside multiline strings. The final re-read before the write is kept.
- **Install versus approval:**
- Install eligibility keeps the previous check: the shipped hook files and the Codex manifest. So a home with extra non-hook files still gets the upgraded plugin.
- The first-install declaration grant and the renewal both require the exact shipped tree, so an unmatched tree is never approved automatically.
- **Main only:** renewal stays in the selected Codex home (#1937).
**Unchanged:** explicit disables, refusals, custom config layers, other people's hooks, and Codex's own baseline. Codex records no "continue without trusting" choice, so a deliberate skip of this exact owned update is overridden. That was the accepted choice.
## Behaviour by case
| Case | What happens |
|---|---|
| Clean shipped tree | A fresh install gets the six exact declarations plus content approval. An owned upgrade renews. |
| An extra non-hook file in the plugin folder, fresh home | The plugin installs. Nothing is approved automatically, so Codex's own review applies. |
| An extra non-hook file, approved upgrade | The new plugin installs. The existing approval stays as it was, with no renewal. |
| A changed shipped hook file or Codex manifest | The Codex plugin isn't installed or upgraded. |
## Proof
- Three review rounds, each read by an independent Claude and Codex pair:
- a config edit that could make a valid file unloadable;
- the install check, which had widened to skip upgrades for any extra file;
- the first-install grant on an unmatched tree.
- The focused suites pass, 179 of 179 on main, with each round's failing control red first. Daemon typecheck is clean, and required CI passes at this head.
- Native upgrade validation on real Codex homes is the release quality lead's, outside this PR.
## Cost
+353/-19:
- source +189/-14 (the adapter +48/-14, the new module +141);
- tests +158/-1;
- docs +6/-4.
No new setting. It adds whole-tree reads on Codex install checks and one receipt per Codex home.
Adds `.github/FUNDING.yml` with one line, `github: mvschwarz`, so GitHub shows its standard Sponsor button on the repository page, linked to github.com/sponsors/mvschwarz.
Nothing else changes: no README, docs or site edits. The repository's Sponsorships setting is switched on separately.
## What a user sees
`rig provider --help` and `rig provider switch --help` said the command switches a seat to an account, with "interruption-safe account switching". It doesn't do that yet. On main, the daemon's `switchAccount` (`provider-service-impl.ts:158-168`) runs the precheck and always answers `failed_safely`:
- **Without `--force-unsafe`:** the answer carries the precheck's reasons.
- A manual precheck assumes a live conversation, because live state isn't read yet. So the reasons always include `would_strand_live_conversation`.
- `target_auth_unknown` is added for an unknown target, and `rebind_unsupported_for_runtime` for a Claude target.
- **With `--force-unsafe`:** the precheck's refusal is skipped, and the answer is `switch_execution_not_yet_wired`.
No account changes either way. The help now says so, including that the plain output prints only the outcome and the reasons are in `--json`.
## The change
Three help strings in `packages/cli/src/commands/provider.ts`, +3/-3:
- the `rig provider` description;
- the `switch` description;
- the `--force-unsafe` description. It used to say the switch "still refuses to strand a live conversation". When forced, though, the service skips the precheck entirely, so that was true only because nothing executes.
There are no behaviour changes. No doc or skill repeats the claim: the only other mention, `docs/as-built/cli-reference.md:764`, lists the flags without their descriptions. No test pins the old text.
## Cost
Help text only. No tests change.
## What a user gets
A seat waiting at a prompt that only its pane shows (for example, a Codex seat at an approval prompt that no hook reported) now reads as
needing attention, with its reason, on every surface: the seat API, the rig and TUI count, the `rig ps` table and the compact JSON. Before
this, the row served `needs_input` (`selection_prompt`, from the pane reading) while `needsAttention` was false, so the table said
"working, —".
## Why it happened
Since `rig ps` renders the served attention flag (#1876), that flag has to agree with the activity the row serves. It didn't:
- `attachTerminalActivityAndWork` derived `needsAttention` from a fresh hook-store read, not from the served `agentActivity`, which also
reads the cached pane observation when the hook is absent or stale;
- the rig count read only the hook store.
## The fix
- **One step, two readers.** The hook-or-pane step of the activity ladder moves, unchanged, into `hookOrPaneActivity`:
1. a positive hook keeps its meaning;
2. a current usage limit stays;
3. otherwise, the cached pane reading answers.
`attachAgentActivity` calls it and applies motion as before.
- **The per-seat flag** reads the served `agentActivity`, and falls back to the hook store where activity wasn't attached.
- **The rig count** reads the same step, with the pane-reading service wired into it, so it can't disagree with the row.
**Unchanged:**
- The arbitrated signal still comes first, so an observed clear from an authoritative rung wins.
- Stale hooks still read `unknown`.
- The pane cache drops stale readings, so nothing stale is revived.
- A confirmed-missing session serves neither hook nor pane, on the row and in the count.
- Everything stays capture-free.
## Tests
`node-attention-consumers.test.ts` drives the real seat route, the rig count, the CLI (JSON and the table) and the TUI reader from one
fixture. It now adds:
- **The native case:** a prompt only the pane reading sees gives ATTN with `selection_prompt` in the table and the compact JSON.
- **The same case after a stale hook,** and **after a daemon restart** with no arbitrated state yet.
- **Controls:** an observed clear still wins, and a current running hook still outranks the pane reading.
CI: RED (the tests alone on main, run 40140037040) fails in `package-tests (daemon)`, as intended. Required CI (8/8) passes at this head. The same change is the 0.7.1 successor candidate `2f6f1f49` (patch-ids checked), and its own RED/GREEN ran the same way (40140033718 / 40140035418).
Follows #1937 and closes two of the gaps it listed as still daemon-scoped.
## What a user gets
- **A one-off `rig up --env CLAUDE_CONFIG_DIR=…` or `CODEX_HOME=…` isn't applied, and the CLI says so.** That value used to put the first
launch on a home that a restore, a handover and `rig seat status` didn't know about: the seat ran on one login while status reported
another, and the next restore moved it back. The note says to declare the home in `env:` in the rig spec. Other `--env` values work as
before.
- **For a Claude seat with its own config dir, activity and continuity read that dir.**
- Activity: the self-report rung finds the seat's `pid.json`.
- Continuity: a policy that watches the seat's transcript, such as a size threshold, finds the file.
Before, both looked only in the daemon's dir and found nothing for such a seat.
## Changes
- **CLI** `up.ts`: leaves `CLAUDE_CONFIG_DIR` and `CODEX_HOME` out of `--env`, with a note.
- **Daemon** `node-launcher.ts`: drops both from any one-off env it receives (`withoutRuntimeHomes`), so an API caller gets the same result.
- **Daemon** `startup.ts`: the Claude self-report reader and the continuity transcript lookup resolve through `claudeConfigRootFor`, in this
order:
1. the seat's declared home;
2. the daemon's `CLAUDE_CONFIG_DIR`;
3. `~/.claude`.
A seat that declares no home reads exactly what it read before.
- **Docs:**
- `rig-spec.md`: the `--env` exception, and "name the home in `env:`";
- the `openrig-user` recipe step 2 says the same, with its edge digest updated.
## Still daemon-scoped, on purpose
Live usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. Its readers are built once with the daemon's
homes, and reading each seat's home would need a native reader per home. `rig-spec.md` now says so.
## Tests
- `seat-runtime-home.test.ts`:
- `withoutRuntimeHomes` drops both names and keeps the rest;
- `claudeConfigRootFor` returns the seat's home, else the daemon's, else `~/.claude`.
- `seat-env-journey.test.ts`: a one-off home reaches neither the seat's terminal nor its forwarded names; other one-off values still do.
- CLI `up.test.ts`: `--env` with both homes and `DEBUG=1` sends only `DEBUG`, and prints the note for each home.
GREEN at `cceedfb1` (run 40131620044): all 8 jobs pass. RED, the tests alone on main (`e9eb005d`, run 40131618183): still queued; I'll add
its result here when it lands.
## What a user gets
`rig up --existing`, and any other resume of a Codex seat, recognizes the resumed conversation when the Codex status footer shows a model name with a reasoning label, for example:
```
GPT-6.1 Sol medium · /data/workspaces/proof-a · …
```
Before this change, once Codex's "OpenAI Codex (v…" header had scrolled off, that footer wasn't recognized. The resume then exited 1 with "Codex resume has not yet reached an interactive conversation" while Codex was running normally. Footers such as `GPT-6-Astra high` already worked: one word after the version was allowed, but a display name plus a reasoning label is two.
## The change
- The model field in a footer may now hold up to three words after the versioned id. That covers a display name and a reasoning label. It applies to both the `gpt-*` field check and the versioned non-`gpt` field, which is still used only when a Codex process is verified.
- Everything else that makes a line a footer is unchanged:
- an indented, ` · `-delimited status row whose whole field is the model;
- a composer line above it;
- trust, update and model-selection gates stay authoritative.
- A longer run of words that starts with a model name is still prose, not a footer, so a status or conversation line can't pass as a ready footer.
## Tests
- **New cases:**
- the reported footer;
- the model field in a later position (`Context 81% left · GPT-6.1 Luna xhigh · /project`);
- a verified non-`gpt` model with two words;
- two prose negatives: a sentence that starts with `GPT-6.1 Sol`, and a field with four words. Both still read as returned to the shell.
- **Before the fix:** the three positive cases fail, and the negatives pass.
- **After the fix:**
- `native-resume-probe`, its evidence tests, `codex-runtime-adapter` and `codex-resume` pass (333 tests), and daemon `tsc --noEmit` is clean;
- the other test files that import the probe pass, as does `session-transport` (270).
## Cost
- Source: 2 regular expressions changed, `{0,3}` in place of `?` for the trailing words, plus the comment (+5/−3).
- Tests: +17.
- There's no new refusal or code, and no runtime cost.
Reported by @george661 on #1764. Thank you.
**Review note:** the longer field follows the shape Codex renders (an optional capitalized display word, one effort label from Codex's closed set, an optional tier word). It's a shape check, not proof against every prose line: a line like `GPT-6.1 default settings · /project` would still match.
For #1932.
## What a user gets
A Pi or OMP seat whose startup sends more than one block no longer raises `runtime_error` ("Agent is already processing") for the second
one. The second block reaches the running turn as a steer.
## Why it happened
`RunnerCore.handleUserBlock` chose between `prompt` and `steer` from its own `streaming` flag, which turns on only at `agent_start`. A block
that arrived after a `prompt` had gone out, but before `agent_start` came back, went out as a second `prompt`. A busy agent refuses that,
and OMP reports the refused input as `runtime_error`.
## The fix
The runner no longer decides. It sends every ordinary block as `prompt` with `streamingBehavior: "steer"`, and the runtime does the rest:
- **When the agent is idle,** the prompt runs.
- **When it's busy,** the prompt is queued as a steer: delivered after the current turn's tool calls, before the next model call.
The window the runner couldn't see is covered too:
- **Pi** counts itself busy from the moment the first prompt's preflight succeeds.
- **OMP** counts itself busy from when a prompt starts.
So a block sent before `agent_start` is queued behind that turn instead of refused. Pi's RPC `prompt` has accepted `streamingBehavior`
since 0.32.2, and OMP's RPC `prompt` accepts it too.
**Why no runner state:** a prompt can succeed with no turn. An extension command or an input handler can consume it in Pi, and OMP reports
`agentInvoked: false` for a consumed builtin. A runner-side "busy" mark would then send later work as `steer` to an idle agent, where it
only queues and never runs. With no runner state there's nothing to get out of step. The runner's `streaming` flag now only labels the
pane's mirror line.
**A side effect:** a `/command` typed mid-turn now goes through `prompt`, so Pi runs an extension command immediately instead of refusing it
as a steer. `pi-runner.ts` is +7/-10 against main.
## Tests
- **Pi and OMP:** the reported order (prompt sent, success response, a second block before `agent_start`) sends two prompts with
`streamingBehavior: "steer"`, and OMP raises no `runtime_error`.
- **After a local-only command succeeds with no turn,** real work still goes as `prompt` and never as `steer`:
- Pi's success with no turn;
- OMP's `agentInvoked: false`, in its response and in a later `prompt_result`.
- **The existing routing tests** (idle, mid-turn, after `agent_end`, rapid submissions) now expect the new `prompt` shape. The mirror line
still says "you (steer)" mid-turn.
GREEN at `c744bac4` (run 40132196534): all 8 jobs pass. RED, the round-1 tests alone on the previous head (`dca2c44e` on `e205a6e4`, run
40132193843): still queued; I'll add its result here when it lands. The first round's RED, the tests alone on main (`d6e2d92c`, run
40127969014), failed the two reported-order tests.
Thanks to astral-fleet-sessions for the report, which traced the order exactly.
**Compatibility:** a Pi before 0.32.2 ignores `streamingBehavior`, so a mid-turn block would be refused there as on main's second prompt. OpenRig can't launch such a Pi, because its Pi launch needs `--name` (added in 0.78.0).
## What a user sees
A Claude Code seat configured with the model alias `haiku` and the saved `plan` permission answers its plan-mode turns with Sonnet. That's Claude Code's own plan-mode behaviour: it upgrades the literal alias `haiku` to Sonnet in plan mode, and a full model id keeps its model. Until now nothing in OpenRig said so; the seat showed `haiku` and answered on Sonnet.
With this change, for that one pairing:
- **The launch result** (`rig up`, `rig seat launch`, a fork or a resume) and **the restore result** carry one line:
`<seat>: Claude Code answers plan-mode turns with Sonnet when the model is the alias "haiku". To keep Haiku, set a full Haiku model id: rig seat set-model <seat> --model <full Haiku id> --reason "<why>".`
- **`rig seat status`** prints the same line under "Permissions, planned for the next fresh managed launch", right after the summary. `--json` has it at `permissions.account.advisories`.
- **`docs/reference/getting-started.md`** ("Per-seat permission mode") gets one sentence saying the same.
**This is disclosure, not an automatic model-selection fix.** OpenRig still passes the configured `--model` and `--permission-mode` unchanged. It doesn't pin an alias to a dated id, add a refusal, or change any launch flag. Choosing the full id is the user's call, and the message names the command for it.
## The change
- `planModelAliasNotice(runtime, { model, permissionMode }, seat)` in `adapters/yolo-mode.ts`, beside `claudePostureFlag`. It returns a note only for `claude-code` with mode `plan` and a model that is exactly `haiku` after trimming, which mirrors Claude Code's own exact check.
- It's called beside the existing non-interruptive notice in `StartupOrchestrator` (fresh, resume and fork) and in `RestoreOrchestrator`'s Claude resume, and from `planPermissionAccount`, which reads `nodes.model`, the same value the launch binds.
- The CLI's `SeatStatusResponse.account.advisories` is optional, so an older daemon that doesn't send it prints as before.
- **Shipped config:** OpenRig ships no spec, built-in team, skill or doc that pairs `haiku` with plan mode, so nothing changes there. The one bare `haiku` that ships is the Claude usage probe (`claude-token-usage.ts`). It runs in print mode with no permission mode, so it's never in plan mode, and the alias keeps it on the latest Haiku.
## Cost
- Source +30/-3 across 5 files, docs +4, tests +129.
- One SQLite read of `nodes.model` per `rig seat status` permission account. Nothing on the launch path except a string check.
## Tests
- **Seat status** (`permission-account.test.ts`): the advisory appears for `haiku` and ` haiku ` with plan. There's none for `claude-haiku-4-5`, `opusplan` or `sonnet` with plan, and none for `haiku` with `acceptEdits` or no saved mode. The native argument is unchanged in every case.
- **Launch** (`startup-orchestrator.test.ts`): the warning is on the result for claude-code `haiku` + plan. There's none for a full id, no mode, or a codex seat, and the binding passed to the adapter is unchanged.
- **Restore** (`plan-model-alias-notice.test.ts`): the restore result carries the warning for `haiku` + plan, and none for a full id or `acceptEdits`. The resume still receives the configured model and mode.
- **CLI** (`seat.test.ts`): the advisory prints on the line after the summary.
**Results on Build:**
- **Before the fix,** against unchanged source with these tests, 10 cases fail: 9 daemon cases and 1 CLI case. The no-note cases pass.
- **With the fix:** daemon 170 passed (1 existing skip), CLI 53 of 53, `tsc` exit 0 for both the daemon and the CLI, and `check-first-run-references` reports 0 failures.
**Still to check live:** a native plan-mode turn on a seat set to a full Haiku id, to confirm it answers on Haiku. This PR doesn't run a model.
**Review notes:**
- `rig seat status --json` now always carries `permissions.account.advisories`, which is empty for every other pairing. The human output and launch arguments are what stay unchanged.
- Claude Code normally answers plan-mode turns for `haiku` with Sonnet. Its source keeps Haiku when every Sonnet is excluded by a model restriction.
A Claude seat with limited Read permissions can finish compaction and then stop at approval for OpenRig's restore marker outside its working directory. Launch and resume now merge narrow Read allowances into the existing session settings: the seat's marker and generated map, the two restore/refocus skill files, its extra-instruction file, and five packet files under the native conversation UUID passed at launch. Fresh, fork and both resume paths use the same composer; forks use the child's UUID. Runtime-switch successors use the canonical seat name rather than their temporary tmux staging name.
Permission mode, existing settings/hooks and native deny/ask rules are preserved. No shared settings or restore writers change. Cost: the next launch/resume adds up to eleven Read patterns, including when managed compaction is disabled; existing running sessions do not acquire them. Operator-selected preparation roots, arbitrary referenced sources and a packet root overridden only inside the seat keep their ordinary permission checks. A legacy name without a native UUID gets no packet-directory allowance. Known limit: packet rules stay bound to the launch token. If an accepted resume rotates T1 to T2, or the native conversation changes later, a packet fallback read may still ask; the seat-keyed marker, map, extra and skill allowances are unchanged. This does not grant every conversation's packets.
Validation: four command-builder regressions failed before the fix; 270 focused tests pass after it, including shell-executed argv capture, default/explicit-mode launch and resume, fork identity, runtime-switch staging, exact generated Read-rule scope, settings preservation and idempotence. Daemon TypeScript passes. The existing 36-cell interactive launch stub also passes. Stubs cannot establish Claude's native Read matching or a completed compact/restore journey; that independent native check is still pending.
When a fresh seat replacement detaches an open terminal tile, the launch result now names the seat and gives commands to reopen its rig view in Herdr/tmux or cmux. The existing warnings appear in human and JSON output, including a failed startup after the old session was stopped. Clean first launches get no replacement warning.
This is recovery guidance: the person rebuilds the view manually. Every confirmed fresh replacement gets the guidance, even if no view was open; it does not count open workspaces or automatically reattach tiles. Other replacement verbs keep their current reporting. Provider code, detach safety and lifecycle decisions are unchanged.
Validation: the missing-warning cases failed before the fix; all 68 fresh-launch tests pass after it, including terminal/Claude replacements, failed startup and a clean-launch control. Daemon build/TypeScript passes. All 52 CLI lifecycle tests pass, including human/JSON warnings on both success and error responses. Desktop providers were traced in source; no native desktop run or live seat changes.
Refs #1939. Thanks @vanducng for the terminal-seat reproduction.
A seat can now run on its own Claude or Codex account. Each runtime keeps its login in its home: Claude Code in `CLAUDE_CONFIG_DIR`, Codex in `CODEX_HOME`. A seat whose member or rig `env:` declares an absolute one runs on that home for the launch and lifecycle paths below, instead of the daemon's:
```yaml
pods:
- id: dev
members:
- id: impl
runtime: codex
env:
CODEX_HOME: /Users/me/.codex-second-account
```
The home is prepared once, natively (`CODEX_HOME=<home> codex login`, or `CLAUDE_CONFIG_DIR=<home> claude` then `/login`). OpenRig copies no credentials or history between homes.
## How
`packages/daemon/src/domain/seat-runtime-home.ts` resolves a seat's home from its stored `env:` (member over rig). A value that isn't an absolute path is reported and ignored. It doesn't reach the runtime: `getEffectiveSeatEnv`, the one source for launch, restore, handover and runtime switch, leaves it out. So the seat resolves as it did before, and nothing is refused.
| Runtime | What follows the seat's home |
|---|---|
| Claude | the managed launch's exported `CLAUDE_CONFIG_DIR`; the first-run bootstrap (`.claude.json`, onboarding and trust); resume capture (`<home>/sessions/<pid>.json`); the fallback resume probe, which runs with the seat's config dir only when one is declared |
| Codex | the launch, resume and fork commands; OpenRig's managed `config.toml`, hooks, feature flag and core plugin; the profile, daemon-support and network-default probes; thread capture, including on adoption and discovered handover (by node); the resume proof, including `rig seat restart --keep-conversation`'s |
| Codex, legacy paths | the legacy resume and restore pass the home only when one is declared, so other seats' calls are unchanged |
| Readers | context usage and the model reader also search declared homes (thread ids are unique across homes) |
| Usage limits | Claude seats with their own home pool apart (`claude:home:<dir>`) from `claude:local`, and the reactive tap skips a Codex seat with its own home, so one account's exhaustion isn't charged to another |
| Status | `rig seat status` shows the runtime home and where it comes from (member, rig, daemon or native default), and any ignored value |
| Provider | `rig provider status` lists the seat's own home as its configured account (`homeFrom`, `ignoredHome`); the daemon home's auth registry no longer binds a seat that has its own home |
Codex homes are passed as a parameter, not adapter state, so concurrent launches for different seats can't cross.
**Still daemon-scoped (not every reader follows the seat yet):** startup's Claude self-report evidence and its continuity transcript lookup still read the daemon's root, and native usage refresh stays daemon-scoped. A one-off `rig up --env CODEX_HOME=…` value still reaches the panes without this resolution.
**A correction to how this worked before:** `CLAUDE_CONFIG_DIR` is one of the managed Claude launch's own keys, so a seat's declared value was never forwarded to Claude, even when the daemon set none. `rig-spec.md` is updated, along with the Codex line.
**Compatibility:** the provider record's new fields are optional, so an older daemon still accepts this one's seat accounts across hosts.
**What doesn't change:** seats that declare no home; shared homes (several seats may name one); a running seat, which keeps its home until its next launch.
**A known limit:** a conversation saved under a seat's earlier home isn't in its new one, so it can't be resumed there. OpenRig doesn't copy it between homes.
## Tests
- **First round:** RED on main `7e69abc1` fails the 21 new tests ([run 40118004305](https://github.com/mvschwarz/openrig/actions/runs/40118004305)). GREEN at `2524764f` passes all 8 jobs ([run 40118018519](https://github.com/mvschwarz/openrig/actions/runs/40118018519)).
- **Review fixes:** RED, with the tests alone on `7b2065f3`, fails exactly the 9 round-1 tests ([run 40125339648](https://github.com/mvschwarz/openrig/actions/runs/40125339648)). GREEN at `77f70a2e` passes all 8 jobs, with 21,151 daemon tests ([run 40127630330](https://github.com/mvschwarz/openrig/actions/runs/40127630330)).
- **Covered:**
- the resolver: member over rig, ignored relative values, nothing declared;
- the Claude launch, bootstrap, resume capture and fallback probe;
- the Codex fresh, resume and fork launches with managed config in the seat's home;
- thread capture on adoption and handover;
- the resume proof and in-place restart;
- legacy resume;
- context usage;
- usage pools by home;
- status and provider accounts;
- unchanged seats.
These tests use fixture executables only and never sign in. Real logins in distinct homes, and the same conversation across resume, restore and handover, still need a native run.
Rex as a terminal provider, by @vanducng. Thank you. The scope is in #1934.
## What a user gets
From a [Rex](https://www.superlogical.com/rex/docs) pane, `rig terminal open <view> --window` and the TUI open the team's view as a new Rex session, one window per page with the same 16-tile grid as Herdr, and switch the Rex app to it. `--provider rex` selects it explicitly. Outside Rex nothing changes: the default order stays Herdr, then cmux.
## The change
- **The provider:** `rex-adapter.ts`, `rex-transport.ts` and `rex-layout.ts` implement the existing `TerminalProvider` seam over Rex's JSON CLI, passing `-S` because this Rex build ignores `REX_SERVER`. Herdr's grid builder moves into a shared `tile-layout.ts`, with Herdr's layout output unchanged.
- **Selection:** the CLI (`terminal-window.ts`, `terminal-window-rex.ts`) and the TUI (`open-window.ts`) pick Rex when called from inside Rex. Inside a Herdr pane, Herdr keeps the view even when `TERM_PROGRAM=rex`, and the CLI and TUI now agree on that. A window opened from inside Rex is refused unless this terminal and the daemon use the same Rex server.
- **Status stays fast:** discovery gives each of Rex's version and status calls 1 s (the pair races a 1.25 s deadline), shares one probe per status report, and the service probes Herdr, cmux and Rex side by side. A stalled Rex reads "did not answer within 1000 ms" while the other providers report normally. Mutations keep their 30 s budget.
- **Uncertain outcomes stay uncertain:** a Rex call that times out, is killed, or returns an unreadable reply is `unknown`, not `failed`. A create whose reply is lost, or names no session, returns `rex_outcome_unknown` and says to check Rex before retrying. A lost window reply keeps the session, names the unconfirmed pages, and never calls `session.destroy`. The session is closed, as before, only when every window definitely failed.
- **Docs and skills:** `terminal-sessions.md`, `getting-started.md`, the as-built adapters page, and the `rigs` and `openrig-herdr` skills.
## Proof
- **CI at this head:** all jobs pass, tested merged with current main. The daemon's terminal-service, rex-adapter and rex-transport suites include a never-settling Rex beside a healthy Herdr (the report returns in under 5 s, using one probe's two 1 s calls), lost create, lost first-page and lost later-page replies (no destroy), and the Herdr-inside-Rex precedence on both the CLI and TUI paths.
- **The author's native runs** (Rex `0.0.1792787366`, API revision 7, two Macs): status, a 4-seat open on each Mac, `--window` switching the app, the TUI preview and open over HTTP, a 17-seat two-page view, and a stalled-Rex measurement at an earlier revision. These are the author's evidence. Our review read the source and CI only.
## Limits
- Not run on Linux.
- Rex counts a block's login-shell startup toward its quick-exit window, so tiles set a 10 s threshold and the adapter checks for exited tiles once, about 2 s after opening. A tile that fails between 2 s and 10 s keeps its error on screen, but the result lists that seat as opened.
- `rig terminal status` with no provider also probes Rex, bounded as above.
## Cost
Source +721/-111, tests +993/-13, docs and skills +82/-20. One new result code (`rex_outcome_unknown`), no new setting.
Found by the lab's onboarding self-scout. Thank you.
## Problem
`rig proof add <absolute slice path>` refused with "resolved to … but no parent mission was found" for a slice in a catalogued project other than the configured one, until `OPENRIG_WORK_ROOT` named that project. `proof add` resolves its missions root once, from `--workspace`, then `OPENRIG_WORK_ROOT`, then the configured `workspace.slices_root`. `findSlice` then looks for the owning mission only inside that root. The read side already selects a catalogued project: `proof show`/`judge --project`, and queue work since #1526.
## Fix
`proof add` calls `findSlice` as before. Only when that refuses an **absolute** path, and no tree was named with `--workspace` or `OPENRIG_WORK_ROOT`, it:
- picks the catalogued project whose root holds the path, with the existing `inferCatalogProject` (the rule `context work-install` uses for a working folder: the deepest containing root);
- reads that project's missions root with the existing `readProjectMissionsRoot`;
- tries `findSlice` there.
If that also fails, the original refusal is reported unchanged. There's no second resolver and no new refusal or setting.
## Proof
- **RED** (`41a784e0`, tests only, on main `dc9feb34`): the slice in the second catalogued project is refused with "no parent mission", the reported error. The two controls pass on main too, as they should.
- **GREEN** (`024978de`):
- `proof`, `proof-project-flag` and `scope-fs` pass 111 of 111;
- the CLI `tsc` is clean.
- **The three cases:**
1. the drop lands in the other project's slice, and nothing is written to the configured project;
2. an explicit `--workspace` still decides, so a slice outside it is still refused;
3. a slice in no catalogued project is still refused.
## Cost
Source +33/-3, tests +67.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a user gets
The two documents that describe the CI scenario job now agree with the job's own script. The job has grown from the two seeded cases it shipped with: its arrays select eight scenarios across fourteen container runs, the library holds twenty scenarios beside its seven stub topologies, and seven of those library scenarios run in CI. A reader preparing a change can now use either document to predict what the CI job will run, which matters because picking a case is how you prove a change before pushing it. The page's own out-of-date list also drops the entry that flagged the README, since the flagged section is the one this change corrects.
## How you verified it
Tested on `e4d7f15a` with the change on top. The counts come from `scripts/run-pr-scenarios.sh` itself rather than from a reading of the docs: `CASES=(fixture library)` with `MODES=(healthy lost-baton healthy)` is six runs, `EFFECT_CASES=(refocus-parked)` with `EFFECT_MODES=(healthy seeded healthy)` is three, and the five `PASSING_CASES` (transcript, capture, daemon-restart, refocus-active, refocus-parked-off) run healthy once each, which is fourteen containers across eight scenarios. The container runner's own case table (`packages/test-system/ci/result.mjs`) is what maps each case name to its scenario file, so the rewritten sentence names the seven library scenarios and the fixture case by their scenario names rather than by case keys. The scenario count is the twenty non-stub YAML files in `packages/test-system/scenarios/` beside the seven `*stub*` topology files. `node scripts/check-docs-guard.mjs` passes, so the tracked-paths rules under `docs/` are satisfied, and `npm run mirror-skills --check` passes. What I could not run: the container job itself, which refuses to run outside GitHub Actions without an explicit `--remote` executor, so the counts are verified against the script's arrays and the case table rather than against a live container run.
## Anything you were unsure about
The page's `last-verified-against-source` marker is deliberately untouched. This change verified the scenario counts and the CI job description against the script, not the whole page, and the marker rules say to move it only when the page has been checked as a whole. The rewritten sentence names the refocus scenarios by their scenario file names (`refocus-parked-baton`, `refocus-active-baton`, `refocus-parked-baton-packet-off`) while the script's case keys drop the suffix; I chose the scenario names because the surrounding text names scenarios, and a reader following along from the case table can match them through the mapping in `result.mjs`. The seeded wording also changes slightly: the fixture and library pairs run healthy / lost-baton / healthy, while the hook case's pair is healthy / seeded / healthy, and the sentence says so rather than flattening the three modes into one shape.
## If this is security-related
Not security-related. The change is documentation only: it corrects the counts and the case names that two docs state about the CI scenario job, and moves no code, configuration or boundary.
- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
Found by the lab's onboarding self-scout. Thank you.
## Problem
`rig scope mission create` writes `mission.yaml` with `metadata: { status: planned }` and no `name`. The project lifecycle compiler requires `metadata.name` (`project-lifecycle-compiler.ts:76`), so a freshly scaffolded mission refuses to compile with `lifecycle_field_missing` until someone adds the name by hand.
## Fix
The scaffold writes `name: "<mission folder>"` beside `status: planned`.
- **Why the scaffold rather than a compiler default:** the compiler names the mission's workflow from this field (`lifecycle-<project>-<mission>`). An explicit name keeps that identity if the folder is renamed, and it matches hand-authored missions and the shipped worked example, which both write `name: <folder>`.
- **Why it's quoted:** a mission name only excludes whitespace and path separators. Written bare, YAML would read `#2` as a comment and `1.10` as the number 1.1.
- **Docs:** `docs/reference/sdlc-conventions.md` now says the scaffold writes the name, and that an older mission without one needs the line added.
- **An existing test updated:** the exact-shape scaffold test (`scope-convention-scaffold.test.ts`) now expects the name.
## Proof
- **RED** (`f25a6c2c`, test only, on main `dc9feb34`): the new case fails.
- A probe compiling main's own scaffold output beside `docs/reference/project-release-profile.yaml` refuses with `lifecycle_field_missing`, the reported error.
- **GREEN** (`5ffd1e8e`):
- `scope-commands` and `scope-convention-scaffold` pass 164 of 164, with the CLI built;
- CLI `tsc` is clean;
- `check-docs-guard` exits 0.
## Cost
Source +5/-2, tests +16/-1, docs +3/-1. No new setting, refusal or error code.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a user gets
Compare labelled Claude token credentials with `rig provider status --refresh --provider claude --claude-token-file /absolute/private/personal.token --credential-label Personal`. Each explicit refresh spends one model turn; status/TUI reads only cached evidence. Windows, resets, age, provider refusal and overage stay distinct. Labels are operator-attested; the older seat/account join stays unknown.
Reuse the refresh coalescer, provider views and reading history. Migration 134 preserves Codex rows and adds credential-labelled Claude readings, never token values. Probe flags use the [official CLI reference](https://code.claude.com/docs/en/cli-reference); real CLI/model execution was not run.
## How you verified it
222 provider tests and 7 migration checks pass; daemon compilation, CLI/TUI typechecks and docs guard pass. Tests use the sanitized event, migrated SQLite, real routes/CLI and fake executable children, including credential isolation, timeout, passive reads, restart and distinct credentials. Full repository/package suites and native provider qualification were not run locally.
## Cost and limits
+454/−40 lines (net +414); 180 test lines/254 added source lines = 0.71. No registry, poller or rotation policy. One credential per explicit refresh; no token-probe fan-out. A timeout does not prove no allowance was consumed.
New opted-in refusal: `invalid_claude_token_selection` (400); a token file must be a regular file of at most 8 KiB; group/other permission bits or a different owner produce a warning, not a refusal. Reader failures: `credential_file_unavailable`, `credential_or_probe_failed`, `probe_failed`, `probe_timeout`, `no_usage_event` (with existing `binary_not_found`). Missing percentages remain unknown; a rejected status is never converted into a percentage.
(Squash body corrected by the merge desk: this merged as migration 134, renumbered for the post-pin order, and round 1 made the token-file owner and mode checks warnings.)
Reported in #1926. Thanks to @george661 for the clear report.
## Problem
A Claude Code seat on Amazon Bedrock is pinned to a cross-Region inference-profile id, such as `global.anthropic.claude-opus-5-5`. Bedrock won't resolve the bare id for these models, so the pin has to keep that form. Claude Code reports the bare model id, `claude-opus-5-5`, and the model-divergence monitor compared the two exactly. So after a daemon restart every such seat was proclaimed divergent: "on a model nobody chose".
## Fix
`modelsMatch` (`model-divergence-monitor.ts`), for `claude-code` only:
- After the exact comparison fails, it drops a recognised Bedrock prefix from whichever side carries it, then compares again.
- The prefix is `anthropic.`, alone or after a cross-Region geography: `global`, `us`, `eu`, `apac`, `jp`, `au` or `us-gov`. Those are the geographies AWS lists for cross-Region inference profiles.
- The existing one-side `-YYYYMMDD` snapshot rule then applies as before, so `global.anthropic.claude-haiku-4-5` also matches `claude-haiku-4-5-20251001`.
- A different model after the prefix still diverges. Nothing matches by containment, and there's no second alias map.
- Codex, Pi and other runtimes keep the literal comparison.
## Proof
- **RED** (`8b12b44b`, tests only, on main `20f77d65`):
- the 13 match cases fail, each proclaiming, including both pins from the report;
- the 8 controls pass.
- **GREEN** (`d223faf2`):
- `model-divergence-monitor`, `pi-effective-model-occupant` and `control-plane-census` pass 135 of 135;
- the daemon typecheck is clean.
- **Controls that still diverge:**
- another model after the prefix (`global.anthropic.claude-opus-5-5` against `claude-sonnet-5-5`);
- a partial id (`…claude-opus-5`);
- a prefix without `anthropic.` (`global.claude-opus-5-5`);
- an unrecognised geography (`ca.anthropic.`);
- two different snapshot dates;
- `codex`, `pi` and no runtime.
## Not covered
- A pin that carries Bedrock's model version suffix, such as `…-v1:0`. What Claude Code reports for such a pin hasn't been measured here.
- An application inference-profile ARN, which doesn't name the model.
Both still proclaim, as before.
## Cost
Source +10/-2, tests +48. No new setting, refusal or error code.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Problem
`rig acp run --help` ends with "Guide: rig context get reference/acp-agents.md". But the `reference` context pack ships only `getting-started`, `instance-layout`, `rig-spec` and `whats-new`, so an agent that follows the help gets a 404. The guide itself exists at `docs/reference/acp-agents.md`.
## Fix
- A symlink `packages/daemon/context-packs-src/reference/acp-agents.md` → `docs/reference/acp-agents.md`, linked the same way as the pack's other files, plus its manifest entry. The pack's purpose now says command help links to it too.
- The help-address test (`scripts/generate-context-packs.test.mjs`) expects `acp-agents.md` in the pack. It reads the `rig context get reference/…` address from `acp.ts`'s help and resolves it through the real address route, so the command help and the pack can't drift apart again.
## Proof
- **RED** (`a3bcb189`, test only, on main `6711432c`): the help-address test fails, and the address check alone shows `reference/acp-agents.md` returning **404** through the address route.
- **GREEN** (`b5863b64`):
- the help-address tests pass 2 of 2;
- all of `generate-context-packs.test.mjs` passes 20 of 20;
- `generate-context-packs --check` validates 57 packs;
- `check-docs-guard` exits 0.
## Cost
One symlink, manifest +7/-2, test +7/-2. No code change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
Slice 22, outcome 9, second increment: what a reader does about a stream item, and the poster seeing it.
- **`rig stream act <id>`** records exactly one action: `--work <qitem>`, `--decline "<reason>"` or `--duplicate <qitem|stream id>`. It runs as the transport identity; `--reader` applies only without one.
- **`act`** links work the reader opened or joined through the queue. It doesn't create work, and the row must exist.
- **`decline`** carries that reader's reason, and is theirs alone: it doesn't become a global no-action or touch anyone else's work.
- **`duplicate`** links the queue row or stream item it repeats.
- **Actions are append-only** (`stream_reader_actions`, migration 133). A reader's newest row is its current action, so a correction stays inspectable. `GET /api/stream/:id/actions` returns the current actions and the history.
- **One accountable action, by context rather than refusal.** When other work is already linked to the item, by another reader or by the automatic path's accepted create or join, `act` still records the link. Its response names that work and the actor, and suggests joining it (a note on that row) instead of keeping a second one.
- **The poster's view (`rig stream show`)** lists each reader's current action, with the actor, the time, and the reason or link.
- **When no automatic work is live** (nothing linked, or the automatic effect ended with intake), the newest reader-linked work decides the stage, followed through handoffs: `work_open`, `work_closed` or `work_unknown`.
- **A second work row** is flagged as `parallelWork`.
- **A daemon without reader actions** shows `readers: "unavailable"` rather than implying there are none.
- **Stream retention** keeps any item a reader acted on.
- **The expired-cursor 410's text** (from #1891) now says `--since <prune mark>` resumes after the pruned region, and suggests the caller's own last seen time or an earlier `--since` for all retained history, since referenced items can be older than the mark.
- **The kernel queue worker's guidance** names `rig stream act` beside `subscribe` and `read`.
## Why this shape
- **The reader links the work it opened.** Work creation stays with the queue verbs the reader already uses, and the action records the accountable link.
- **Telling, not refusing.** A second reader with different work is told about the first. A refusal would make OpenRig track the attempt's state, and the reader can judge whether to join.
- **Corrections stay inspectable:** append-only rows mean a changed mind is a newer row, not an overwrite.
## Cost
- **Net lines:** source +193/−4 (the action module 85, the outcome view 32, the CLI verb 23, routes 23 including the 410 wording, the migration 26, retention 2, registration 2); tests +154/−1; docs +1; kernel guidance +3/−2.
- **New error codes:** `action_invalid`, `work_required`, `reason_required` and `duplicate_required` (400); `stream_item_not_found`, `work_not_found` and `duplicate_target_not_found` (404). These are input checks on the new verb.
- **New refusals beyond input validation:** none.
## Tests
- **`packages/daemon/test/stream-reader-actions.test.ts` (5):**
- act, decline and duplicate record the actor and the link or reason, and each malformed input gets its code;
- two readers keep one accountable action: the second reader is shown the first's work and the automatic path's;
- a decline is that reader's alone, and a correction keeps the earlier action in the history;
- retention keeps an acted-on item;
- routes act as the transport identity.
- **CLI `stream-outcome.test.ts`:** readers are shown; a decline alone changes nothing; reader-linked work decides the stage (open, closed with its resolution, through `with_intake`); `parallelWork` when automatic work differs; `readers: "unavailable"` on an older daemon.
- **CLI `stream.test.ts`:** `act` sends exactly one action, and refuses zero or two.
- **`stream-subscriptions.test.ts`:** the 410 message carries the new wording.
- **Mutation checks:** each broken rule made its targeted test fail, and the source was restored and compared:
- dropping the other-work notice;
- dropping the reader-decides stage.
- **Other checks:**
- daemon `stream-subscriptions` 10, `stream-routes` 26, `stream-store` 11, `stream-dispositions` 12, `queue-retention` 14, `migration-fixture-parity` 5, `s02-p1-classification-persistence` 29, `startup-migrations-mirror` 2;
- CLI `stream-consumer` 22, `stream-outcome` 11, `stream` 15;
- daemon and CLI `tsc` clean;
- `npm run test:repo`: 309 pass, 0 fail.
## Round 1
- **Retention keeps a stream item a duplicate action names,** for corrected (historical) actions too.
- **One handed-on chain isn't competing work.** The `act` notice and `parallelWork` compare work by its current row, handoffs followed.
- A link whose current row is unknown keeps its own id.
- The notice gives each named link's `current` row and, for automatic work, how its application ended (`applied`).
- **Same-millisecond actions** are ordered by their monotonic action id, which is record order.
- **Cost of this round:** source +48/−11, tests +32/−2.
- **REDs first:** review-r2's three fixture cases fail on `d7c737e2`'s source and pass after:
- a duplicate-named item pruned;
- a handed-on chain named as other work and flagged `parallelWork`;
- a tie choosing the older closed work.
- **Checks:** daemon `stream-reader-actions` 7 and the stream set (75), CLI `stream-outcome` 11, `stream` 15, `stream-consumer` 22; daemon and CLI `tsc` clean; `test:repo` 309/0.
## Note
- **Migration 133:** #1897 takes 131 and #1895 takes 132. Whichever merges second may need a rebase of the migration list here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a user gets
Two of the scenarios in the library can now run as checks instead of sitting in the not-yet-runnable table. The scenario format declares five emit behaviors, and the runner bound one of them: `refocus` fired the real refocus hook, while `compaction`, `restore`, `slow_output` and `mid_turn_death` threw an unbound-action error by name. The `compaction-restore-resumes-role` scenario uses exactly the two behaviors whose shipped seams already exist, so it read as a scenario waiting on its runner. That runner gap is now closed for those two: `emit compaction` fires the packaged precompact hook the same way the stub runner's own script step does, so the seat gets its keyed restore-pending marker, and `emit restore` fires the restore bridge, which reads that marker and injects its one-shot directive, which the runner then mirrors to the seat's pane. `slow_output` and `mid_turn_death` stay unbound and still refuse by name, because their stub behaviors are pane mechanics rather than shipped seams, and binding them for real is a different piece of work.
## How you verified it
Tested on `e4d7f15a` with the change on top, on Windows with Node 24.21.0. The new `packages/daemon` `scenario-emit-behaviors.test.ts` passes 5 of 5. It covers the refusal of a behavior with no binding, the compaction seam receiving the seat's own identity (session name, the transcript path the stub runner derives from the seat cwd, the scenario daemon's `OPENRIG_HOME`, the seat's managed cwd) and reporting the marker it wrote, the restore seam receiving the same identity and its directive mirrored to the pane verbatim through the product's send path, a restore that delivers nothing failing loud instead of passing silently, and a seam error surfacing with its own message. The two behaviors are injectable on the runner options, which is what lets those tests assert the identity without running tmux. `packages/daemon` `scenario-normalizer.test.ts`, `stub-script.test.ts` and `run-scenarios-flag-refusal.test.ts` all pass (34 cases together), so the format, the stub script model and the CLI flag guard are unchanged. `npx tsc --noEmit` passes for the daemon project and `npm run lint` passes for all four packages. What I could not run: the `compaction-restore-resumes-role` scenario itself needs a scenario runner with tmux, and this machine has none, so the end-to-end run is the one proof still outstanding; the dispatch, the identity and the mirror text are covered by the unit tests. I also did not run the full `npm test` or the UI suite.
## Anything you were unsure about
The mirror of the restore directive goes through the product's send path, which the scenario file's own header describes as the runner mirroring the delivered directive to the pane verbatim. An alternative would have been a new control channel into the running stub, so the pane mirror comes from inside the seat the way the script steps do, but that is a change to the stub runner's contract rather than a binding, and the send path already delivers a terminal seat the exact text. If you would rather the mirror come from inside the seat, that is a follow-up I am happy to take. The `slow_output` and `mid_turn_death` behaviors are deliberately left unbound: firing them for real means pacing pane output and killing a seat mid-turn from the runner, which is a separate design decision. The compaction seam is given the stub seat's own transcript path rather than letting the hook discover one, because the restore bridge's premise gate delivers only when the event's transcript matches the one the marker recorded, and the stub's own authored transcript is the deterministic choice.
## If this is security-related
Not security-related. The change is inside the test harness: no route, credential, boundary or product behavior moves, and the seams it fires are the same shipped hook scripts the stub runner already executes for its own script steps.
- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
## Why
The packaged capability map (`onboarding-width/public-what-you-can-do.md`) still teaches through `capability-delta-v0.7.0`. So a fresh agent on 0.7.1 can't discover what 0.7.1 delivered unless it already knows which `--help` to ask. This refreshes the map on main, so the next package carries it. It's docs only.
## What changed (one file, +130/-24)
Everything comes from the 0.7.1 cut: its CLI and daemon source, and its packaged `reference/whats-new.md`.
- **The header** names `capability-delta-v0.7.1` and ninety top-level verbs (88 in 0.7.0, plus `acp` and `edge`). It points to `rig context get reference/whats-new.md` for what changed in the installed version.
- **Finding out what's true:**
- `rig ps --nodes`'s `ATTN` column, and reading it through `--json`/`--fields` rather than by column position.
- `rig seat status` showing a seat's planned next launch and its terminal policy.
- The `needs-input` pickup state.
- **Reaching another agent:**
- `rig send --urgent`.
- The override's stop-and-show on an input box that already holds text, plus `--type-without-checking`, with the combinations not yet checked on a live seat.
- The wider `target_needs_input` rule.
- `rig stream subscribe`, `read`, `subscriptions` and `unsubscribe`, `rig stream show`, and stream retention.
- `rig acp run`.
- Herdr tiles reattaching after a Herdr restart.
- **Work, wakes and teams:**
- `rig queue update --evidence-ref` on a close.
- `rig watchdog update` and the token/percent context thresholds.
- `rig up --name`, `rig edge add|remove`, and per-member Claude setup (`agent_file`, `append_system_prompt_file`, `plugin_dirs`, `advisor`).
- The community-harness conversation fields (`launch_result`, `resume`, `names_conversation`).
- **Recovery and settings:**
- `daemon.db_conflict` and `--allow-db-conflict`.
- `policies.claude_compaction.preparation_root`, and managed compaction resuming after a daemon restart.
- The typing-guard levels.
- `rig seat set-terminal-policy`.
- `provider.selected` and `rig setup --router-line`.
- Remote `--host` on `rig auth` and `rig seat restart`.
- The long-lived-token usage limit.
- **Tracking work:** `rig project wake --decide` and `rig project decisions status`, marked opt-in and off by default.
- **People:** every ask lands on the Feed, the person's `slack-subscription` decides what reaches Slack, and `--alert` is for what only they can do.
- **MCP:** the delegation-loop tools, `rig mcp serve --http` and `rig mcp token add`.
Opt-in and off-by-default features, and the known limits, are marked where they apply. Nothing merged after the 0.7.1 pin is described.
## Checks
- `node scripts/check-docs-guard.mjs` passes.
- There's no other copy of the map in the repo to keep in step.
- `generate-context-packs --check` needs a built daemon, so CI's repo checks cover it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
The release substance gate (`npm run gate:substance`) refuses `docs/reference/agent-startup-guide.md` because one accurate
sentence names the default compaction preparation root, `<cwd>/.openrig/compaction/preparation/`, and that path prefix is
on the deny list.
This adds that one sentence to `allowed_context_lines` in `scripts/internal-tokens.generated.json`, the existing exact-line
allowance (the file is the policy source despite its name; `gen-control-plane-json.mjs` copies it and never rewrites it).
The whole line must match, so appended text or a concrete path in place of `<cwd>` is still refused.
Unchanged: the deny tokens, the path prefixes, the guide, the gate and every runtime source.
## Tests
`scripts/internal-leak-scanner.test.mjs` gains one test against the product rules:
- the shipped guide scans clean, at its repo path and its packaged path;
- the sentence with appended text, or with a concrete directory in place of `<cwd>`, is still refused for `.openrig/compaction/`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What this gives a person
When a provider reaches its usage limit, or a seat would do better on the other runtime, a person or their operator agent can move that one live seat between Codex and Claude Code without rebuilding the team:
```bash
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit" --dry-run
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit"
```
The seat keeps its address, connections, open queue rows and in-progress claims, and its successor gets a handover of what it was doing. The new runtime takes over before the old one stops. If anything goes wrong before the cutover, the current occupant keeps the seat, and `rig seat status` says so.
This builds on the design in #1214 by @Aschenbath (a staging session with a pane swap, a durable switch record, one commit transaction, recovery that rolls back before commit and finishes forward after, and re-resolving the seat's materials for the target), rebuilt on today's main with the changes below.
## How it works
1. **Preview (`--dry-run`).** It lists the target's settings and where each comes from (an explicit option, the seat's agent spec for the target runtime, or the runtime's default), what is cleared, startup slash commands a switch doesn't run, and the costs. A seat that can't switch (another runtime, more than one pane, no current occupant, a target that isn't installed, materials that don't resolve, a switch already open) is reported here, before anything stops.
2. **Successor first, under the seat's lifecycle lease.** The switch is journaled (`seat_runtime_switches`), then the successor starts in a staging session with the seat's name, a reserved occupant generation and the target's own materials (its seat skill storage, projections and startup files, with the identity's `runtime:` line rewritten).
3. **Handover and confirmation.** The successor receives the predecessor's screen, the seat recap and its open rows, and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "<what I'll do first>"`. The daemon accepts that only from the successor's own session and reserved generation. This is the evidence that the successor can carry on: its model read the handover, its tools run, and it reaches OpenRig as the seat. With no confirmation within 180 s (`--confirm-timeout`), nothing commits, the staged successor is retired, and the predecessor keeps the seat.
4. **Cutover.** The panes are swapped, then one transaction commits the seat's runtime, cleared settings, permission selection, startup context, session, binding and tenure. It moves in-progress claims from the predecessor's generation to the successor's, takes a snapshot for the switch (restore tier, so the newest default restore matches the new runtime), and records a `seat.runtime_changed` event. The predecessor is then retired and the successor is told it now holds the seat.
5. **Recovery.** At daemon start, and with `rig seat set-runtime <seat> --recover`, an interrupted switch is finished from its record: before commit it is rolled back (panes swapped back, staged successor retired); after commit it is finished forward and never handed back. Where the panes match neither side, nothing is touched and status says why.
**Who holds the seat** is decided by one fact, the switch's `committed_at`, which is set in the same transaction as the configuration. Its `phase` records physical progress, and `attention` records why recovery stopped.
## The checks stay narrow
During a switch, the predecessor and successor share the seat's session name. To tell them apart, the CLI now sends the caller's own occupant generation (`X-OpenRig-Occupant-Generation`, from the seat's `OPENRIG_OCCUPANT_GENERATION`). The daemon refuses only the two generations a switch record names, on the seat it names, on queue create, claim, unclaim, update, handoff and handoff-and-complete, send and broadcast, and every mutating seat route (`set-model`, `set-effort`, `set-permissions`, `set-cwd`, `handover`, `stop`, and the rest):
- `occupant_retired_by_switch`: the predecessor after commit;
- `switch_not_committed`: the staged successor before commit, except its own `--confirm` and `--recover`. A successor whose switch was abandoned stays fenced while its retirement is unconfirmed (the switch keeps attention), and recovery at the next daemon start retires it; a failed retirement isn't a release of custody.
No header, an unknown generation, re-adopted seats and every other seat behave exactly as before. The activity hook ignores a retired predecessor and a staged successor, and keeps the staged successor's native identity on the switch record rather than the seat.
## Refusals and error codes
The operation itself: `invalid_runtime_settings`, `runtime_unchanged` (it names `set-model`, `set-effort` and `set-permissions`), `runtime_switch_unsupported`, `runtime_target_unavailable`, `runtime_switch_pending`, `runtime_switch_failed` (`committed: false`), and `runtime_switch_confirmation_refused` on the confirm route. The CLI adds `runtime_switch_outcome_unknown` when it stops waiting (it points to `rig seat status`). On existing paths, there are two: `occupant_retired_by_switch` and `switch_not_committed`. The existing lease refusal `draft_aware_lifecycle` is unchanged.
A `rig send` to a seat that is switching isn't refused or kept waiting. The daemon holds it at once on the existing held path (`outcome: retained`, `reason: runtime_switch_in_progress`) and says the seat is switching and until when; a send that had already been waiting when the seat moved says nothing was typed and to send again. Both are on the send's own answer, so the CLI makes no extra request.
## Costs (also in the preview and `docs/reference/getting-started.md`)
- A new conversation: native history isn't converted; only the handover carries over.
- The same address, a different pane and scrollback.
- The team file isn't rewritten. The database and `rig export` show the new runtime, while an imported YAML keeps the old one, so a later converge reports a deferred `change_runtime`.
- Source settings are cleared, not mapped: model, effort, Codex profile and an explicit native permission choice. A model or effort the agent spec declares for the source runtime doesn't apply either. Member and rig permission policy are kept.
- Two processes until cutover.
- Messages sent to the seat during the switch are held, not typed, and need sending again afterwards.
- A failed switch can leave the target's prepared files.
- Codex and Claude Code, single-pane seats only.
## Known limits
- Claude launch additions such as an agent file aren't applied to a switched seat.
- Startup slash commands aren't run; the preview lists them.
- On a seat with the draft-aware typing guard, a person typing into the old pane during the switch stops it (the guard protects their draft), and the predecessor keeps the seat.
- A successor's context-usage sidecar may be cleared at commit by the name-keyed invalidator; it is rewritten on the next reading.
## Tests
All with fakes (no real agent seats). Each test states what the person or calling agent sees.
- **New:**
- `seat-runtime-switch-service`: preview, both directions, confirmation, no confirmation leaving the predecessor, exact claims, recovery at each point, and the fence on a switch's two processes. An abandoned successor that couldn't be retired stays fenced until recovery retires it, and a retired predecessor is refused on `set-model` and 11 other mutating seat routes while reads and other callers are unchanged. These cases failed first against the code without the fence.
- `runtime-switch-custody`: the two refusals and the unchanged cases. A send to a switching seat is held at once without being typed; a send after commit goes as usual; a send that waited while the seat moved says so; a seat with no switch is unchanged.
- `runtime-switch-materials`: settings from the seat's agent spec per runtime, cleared settings, target storage and startup files.
- `seat-runtime-switch-route`.
- CLI `seat-set-runtime` and `send-runtime-switch` (one request, the daemon's answer shown), plus a block in `client-headers`.
- **Daemon:** 38 files, the new ones plus the neighbouring suites (handover, launcher, queue, transport, activity, snapshots, restore, startup, tmux, migrations, stream subscriptions): 1,385 passed, 5 todo.
- **CLI:** `seat-set-runtime`, `send-runtime-switch`, `client-headers`, `local-owner-routing`, `send`, `send-header`, `seat` and `seat-lifecycle-verbs`: 8 files, 256 passed.
- **Build and docs:** the daemon build and CLI `tsc --noEmit` are clean, the first-run reference check reports 0 failures, and the docs guard passes.
Not run here: switches between real Claude Code and Codex seats in both directions, a real confirmation and a successor stuck at an approval prompt, a seat switching itself, real interruptions with a daemon restart, and a real restore after a switch. These need real accounts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Problem
A Claude seat pinned to an undated model id, such as `claude-haiku-4-5`, answers with that model's dated snapshot id, `claude-haiku-4-5-20251001`. The model-divergence monitor compared the two strings exactly, so it proclaimed a divergence for a seat running exactly the model it was pinned to. Every Haiku seat pinned this way gets the false alert, and false alerts teach people to ignore the real ones.
## Fix
`modelsMatch` (`model-divergence-monitor.ts`), for `claude-code` only: after the existing exact comparison (pin through `CANONICAL_MODEL_PINS`, `[1m]` dropped), when exactly one side ends in a `-YYYYMMDD` snapshot date, the comparison drops that date.
- `claude-haiku-4-5` matches `claude-haiku-4-5-20251001`, and a pinned snapshot matches its undated id.
- Two different snapshot dates still diverge: someone who pins a dated snapshot means that one.
- `CANONICAL_MODEL_PINS` stays the single alias home, and nothing matches by containment. An unmapped alias such as `haiku` still diverges, as before.
- Codex, Pi and other runtimes keep the literal comparison. The Pi path's runtime-less call is unchanged.
## Proof
- **RED** (`ac8bf31f`, tests only, on main `a65262da`): the five match cases fail, because each proclaims; the eight divergence controls pass.
- **GREEN** (`a8ab416e`): `model-divergence-monitor`, `pi-effective-model-occupant` and `control-plane-census` pass 114 of 114, and the daemon typecheck is clean.
- **Controls that still diverge:** another family with a date (`claude-haiku-4-5` against `claude-sonnet-4-5-20250929`), two different dates, a prefix pin (`claude-haiku-4`), a trailing extra after the date, an unmapped alias (`haiku`), and `codex`, `pi` and no runtime.
## Cost
Source +9/-1, tests +40. No new setting, refusal or error code.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a person gets
A seat stuck at a choice prompt while it holds open work now reaches someone. Before, a Claude seat at its usage-limit dialog could sit for many hours: every wake was correctly refused as `selection_prompt`, and nothing escalated. Addresses #81.
## Why it stayed silent
The prompt-refusal pass escalated only when the seat's own evidence read "blocked". For Claude, that evidence comes from a permission request a hook announced, or a hook that means "waiting on a person". A choice dialog such as the usage-limit one announces neither, so the read stayed "unknown" and the pass skipped the seat on every tick.
## The change
- **The refusal counts as evidence.** The transport's refusal names the prompt it read. While a seat's latest refusal is at a choice prompt (`selection_prompt`), the oldest open choice refusal that nothing newer has cleared reads "blocked" once it has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 s by default). A prompt answered within seconds never escalates, a cleared choice prompt lends no age to a later refusal, and a choice prompt that stays up keeps its age when new work's refusal arrives. An episode closes when the seat resumes, and a clear or move-on hook retires the alert as before.
- **Choice-prompt alerts follow `queue.wake_human_rung`.** An alert whose every refusal was at a choice prompt is tagged `prompt-kind:choice`. Under `explicit-only` it stays with the operator seat as a visible row and posts to no person, unless that seat is a person. A permission prompt, which only a person can approve, keeps posting as before, including one that later joins an alert the setting kept from a person; delivery already accepted is never repeated. The default (`always`) is unchanged.
- **One choice-prompt alert to a person at a time on a machine.** While one choice-prompt alert has gone to the delivery engine and stays open, each other choice alert names it ("operator waiting on …") and waits, then posts after that one retires. A prompt many seats hit together, such as a shared usage limit, posts once instead of once per seat. Permission-prompt alerts never wait behind it. The waiting alerts stay visible rows.
- **Unchanged:** nothing is typed into the blocked seat; other refusal reasons with no evidence wait for evidence as before; the orchestrator rung still comes first.
## Who it reaches
- **The orchestrator rung:** the seat's orchestrator (the source of its `delegates_to` edge) is woken on one aggregate row. If that wake lands, the ladder stops there.
- **The operator rung,** when the seat has no orchestrator or its wake fails: the aggregate row goes to `workspace.operator_seat_name`, and the delivery engine posts an ALERT to the first entry in the human registry. With no registered human, the rung stays unresolved and the row is the only signal.
- A permission-prompt alert reaches that person whatever `queue.wake_human_rung` says. A choice-prompt alert does so under `always` (the default); under `explicit-only` it reaches a person only when the operator seat itself is one.
## Tests
- **End to end** (`held-wake-escalation.test.ts`): a Claude seat whose pane shows a choice dialog, with no hook. The real transport refuses the wake and records `(selection_prompt)`, the production prompt reader reads unknown, and after one retry interval the seat's orchestrator is woken. The seat gets no input.
- **The pass** (`prompt-blocked-alert.test.ts`):
- a choice-prompt refusal with unknown evidence: no alert inside the interval, then one post to the registry's first human after it, and never a second;
- two seats blocked together: one post, the other alert names it and waits, then posts after the first seat's prompt clears;
- a permission-prompt seat beside a choice-prompt seat: both post, and neither waits;
- under `explicit-only` with an agent operator seat: the choice alert stays with that seat, tagged, and posts nothing, while a permission alert beside it posts; a reminder refused twice at a choice prompt follows the setting too;
- a choice prompt cleared before a new choice or permission refusal lends it no age (both quiet), and one that never cleared keeps its age when new work's refusal arrives;
- under `explicit-only`, a permission refusal that joins a choice alert kept from a person posts once, as a new episode or as a reminder whose latest refusal changes kind;
- guards: another refusal reason with unknown evidence still waits; a choice prompt with newer clearing evidence stays quiet.
- **RED on main** (the first cases only): the end-to-end case wakes nobody, the single seat gets no post, and two seats get two posts. The guards pass there too.
- **RED on the first version of this change** (the later cases): the permission alert is held behind the choice alert; a cleared choice prompt's age raises an alert; under `explicit-only` the choice alert still posts; the reminder alert carries no choice tag. The continuity case passes there too.
- **RED on the second version** (the last two cases): the joined permission alert posts nothing.
- **GREEN here:** every CI job; `prompt-blocked-alert.test.ts` 80 tests.
## Limits
- With the default setting, a usage-limit dialog reaches the same person as any other prompt alert, at most one choice alert at a time per machine. Sending it to an ops seat instead takes `queue.wake_human_rung: explicit-only` and an operator seat that isn't a person; this change doesn't set either.
- Every choice prompt follows the setting, not only usage limits: under `explicit-only`, a choice only a person should answer (a resume picker, say) also stays with the operator seat, whose agent escalates it through the usual paths.
- While the first posted alert's seat stays blocked, later alerts on that machine wait unposted; they're visible as rows.
- Only `selection_prompt` refusals use the new rule. A watchdog reminder refused twice at a prompt keeps its existing path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a user gets
When a terminal client disconnects while an earlier input write is still in flight, later input that was already queued is no longer sent to the seat after the broker tears down. The write already in progress is allowed to finish; only queued writes that have not started are dropped.
## How you verified it
- On base `ccdce88e`, the deterministic regression test fails: the broker records and sends both `first` and queued `second` after teardown.
- On HEAD `091f28a3`, `npm run test -w packages/daemon -- test/terminal-session-broker.test.ts test/terminal-ws-route.test.ts` — 59 passed.
- On HEAD `091f28a3`, `npm run lint` — passed (daemon build plus TypeScript checks for daemon, UI, CLI, and TUI).
- `npm test` and the root `npm run build` were not run; the focused daemon tests and repository lint/typecheck cover the changed module and its route neighbor.
## Anything you were unsure about
The guard is inside the serialized input callback so it covers writes queued before teardown without changing the ordering or delivery of inputs while the broker is live.
- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
## What a user gets
Refs #1520.
`rig queue whoami --json` now resolves a project-tagged in-progress row against its catalogued project's missions root only when the row has exactly one distinct `project:` tag and that catalog entry is healthy. `--candidates` uses the same per-item resolution while keeping its host mission inventory and default `missionsRoot` on the configured `workspace.slices_root`.
Rows without a project tag retain the existing configured-root behavior. Missing, invalid, unreadable or duplicate catalogs, unknown or broken project entries, and multiple distinct project tags fall back to the configured root and explain the real reason in the basis instead of guessing or claiming that no root is configured.
## How you verified it
- Base-fail probe on the pre-fix `origin/main`: with catalogued `alpha` and `beta` projects sharing `release/slices/01-work`, `workspace.slices_root` pointed at beta, and an in-progress `project:alpha` baton, `whoami` returned beta's work-node path.
- Fix-pass: the same focused regression returns alpha's path for both `currentWork` and the opt-in `workCandidates` projection.
- `npm run build -w packages/daemon` — passed.
- `npm run lint` — passed.
- `npm run test -w packages/daemon -- test/current-work.test.ts` — 14 passed.
- `npm run test -w packages/daemon -- test/queue-routes.test.ts` — 209 passed, including route-level fallback coverage for missing, invalid, duplicate, unknown, broken and multiple-tag cases, plus candidate metadata.
- `test/refocus-work-packet.test.ts` was attempted separately; its 15 failures are in the native-Windows trace/resolver fixture path (CRLF/Unicode rendering and unavailable resolver command), not in the changed current-work unit or queue-route suite.
## Anything you were unsure about
- The route reads the existing project catalogue only when available. If the catalogue cannot be read, it preserves the configured-root behavior and records that fallback.
- The maintainer's separate decision about whether a selected project's root may fall back to the configured root for a missing mission/slice remains intentionally unchanged.
## If this is security-related
Not security-related. The bug affects queue context/refocus correctness inside a trusted OpenRig workspace.
- [x] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [x] Tests added or updated where the change is testable
- [x] I listed the checks I ran, their results, and any checks I could not run
## What this does
Two changes for a Slack workspace with more than one registered person, so a reply that comes through the gateway keeps its sender's identity.
1. **A Slack reply from someone else doesn't resume another person's question.**
- When a different registered person replies in Slack to a question parked on someone else, their reply still reaches the agent as their own message.
- The question stays parked on its person, and nothing is written to it.
- Unchanged: a typed reply to a direct ask was already checked against its recipient, and a different person's button click is still ignored.
2. **Slack rows name who actually wrote.** A Slack message, answer or reaction lands on its row with a summary naming the registered person who wrote it (`<address> via Slack: …`), not one fixed title. Tags are unchanged.
## What doesn't change
- An action at the terminal (the TUI, the Feed, Mission Control's verbs) is the primary user's. It keeps its existing authority and is recorded as it is today.
- Agents are unaffected.
## Tests
- **New:** `slack-reply-from-another-person.test.ts`. A different person's Slack reply leaves the parked question untouched, while a terminal resolve of the same question works as before.
- **Updated:** the Slack inbound summary tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What and why
**The harm:** after an operator declined a Claude seat's permission prompt ("No"), the seat stayed unreachable. Every `rig send` was
refused with `(PermissionPromptConfirmed; latest hook, pane unrecognized)`, and `rig seat clear-attention` answered `not_in_attention`.
**How it happens:**
- **Where the state comes from:** `PermissionPromptConfirmed` is OpenRig's own record. The activity poller writes it when it sees a permission prompt on screen
(`startup.ts`, `permissionPromptChanged`), and it writes `PermissionPromptCleared` only once its screen read says definitely no prompt.
After the decline, the screen read as unrecognized, so nothing ever recorded the prompt cleared.
- **The send gate:** past the 15-second send window it reads the pane. When the pane is unrecognized, it keeps a latest hook that says the
seat waits on a person (`classifySendReadiness`). That rule is deliberate: it stops a send from pressing Enter on a picker the pane can't
show. So the decline left a refusal that nothing could clear.
- **`clear-attention`:** it knew only the startup, restore and pane-identity classes.
**What changes:**
- **A new class for `clear-attention`:** a latest hook that still says the seat waits on a person, or a Codex pending permission.
- Without `--reason`, it says what to do instead of `not_in_attention`.
- With `--reason "<what you saw>"`, it records `PermissionPromptCleared`, the event the poller writes when it sees a prompt go, and an
`attention_cleared` event carrying the reason.
- Sends and wakes then go through. The send gate still reads the pane on every send, so a prompt it recognizes still refuses.
- **The refusal names the way out:** when a refusal rests on a retained hook alone (a Claude waiting hook, or a Codex permission request the
pane can't confirm), it adds "Look first: `rig capture <seat>`. If that prompt is gone (declined or answered), clear it:
`rig seat clear-attention <seat> --reason "<what you saw>"`." The CLI help mentions the class.
**Why not automatic:** recognising the post-decline screen would let the poller clear on its own, but no capture of that screen exists.
Treating every unrecognized pane as clear would drop the picker protection. The operator who declined the prompt can say it's gone.
**The activity service too (round 1):** the clear also settles the activity service's pending prompt
(`SeatActivityService.settlePermissionPrompt`, wired in `startup.ts`). So the wake ladder, which asks that oracle first, and `rig ps` agree with
the clear instead of reading the seat blocked. A new permission hook starts a new request.
U3 (#1892) is merged, and this builds on it.
## Cost (net vs main, after round 1)
| | Lines |
|---|---|
| Source: `seat-attention-reconciler.ts` | +38/-5 |
| Source: `session-transport.ts` | +16/-4 |
| Source: `seat-activity-service.ts` | +16 |
| Source: `startup.ts` | +1 |
| Source: CLI help in `seat.ts` | +6/-2 |
| Tests | +107/-2 |
- **New refusals:** none of anything that works today. The new class only adds a way to clear; without `--reason` it explains instead of
clearing.
- **New codes or settings:** none.
## Proof
**Round 1** (`440482bd`):
- **RED** (round 0's four source files, the round's tests): 6 fail. The activity service still counts the prompt after the clear (1, not 0).
The four Codex hook-only refusals lack the hint, and the Claude hint lacks "look first".
- **GREEN:** 15 files, 744 tests, adding the Claude and Codex permission-pending and held-wake escalation files. The new case runs through
a real `SeatActivityService` and `makePromptStateReader`: blocked before the clear, clear after, and no re-confirmation on the next poll.
- **Typecheck:** daemon `tsc` is clean.
**Round 0:**
- **RED** (main's two source files, the new tests): 3 fail.
- `clear-attention` answers `not_in_attention` with and without a reason.
- The refusal carries no hint.
- **GREEN, the touched files:** 133 tests across `seat-attention-reconciler` and `send-prompt-guard`.
- **GREEN, adjacent files:** attention, needs-input provenance, claim-hint prompt guard, parked-owner consumer, prompt answer delivery,
prompt-blocked alert, prompt-clear continuation, wake ladder, sessions routes, session-transport. 694 in total.
- **Typecheck:** daemon `tsc` is clean.
- **The CLI change** is help text only; no CLI test pins it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`rig <verb> --help` lists each subcommand with its description. These 14 had none, so their parent's help showed a blank summary:
- `rig chatroom send`, `history`, `watch`, `topic`, `wait`, `clear`
- `rig project experimental enable`, `disable`, `status`
- `rig context profile`
- `rig health diagnosis record`
- `rig env status`, `logs`, `down`
Each new summary is taken from what that command's action does.
`lazy-program.test.ts` gains one case. It walks `createProgram()` and requires every command to have a description, so a new command can't land with a blank summary unnoticed. The same traversal finds the 14 blanks at `7923e13e` and none on this branch.
Text only, apart from that test. No behaviour, option or output changes. `docs/as-built/cli-reference.md` doesn't list descriptions, so it is unchanged.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What a user gets
Codex resume detection recognizes a resumed Codex TUI whose status footer uses a versioned non-`gpt-*` model identifier, including the warning layout reported in the issue.
The process-corroboration proof now reaches every relevant path:
- Runtime adapter readiness and resume checks.
- Strict full-restore reconciliation of an already-flagged agent.
- Startup route pane observation.
The strict reconciler reuses its existing exact Codex resume-token process proof instead of performing a second identity check. The startup route uses the shared process census for the same verify-and-reprobe behavior.
Non-`gpt-*` footer evidence is accepted only when its status row appears below the last composer line. This prevents a stale shell capture such as `project2 · main` followed by `› codex resume ...` from being promoted when Codex has only just become the foreground process.
Trust, update, hook, and model-selection gates remain intact.
## How you verified it
- Probe and startup route tests — 176 passed.
- Focused runtime stale-shell and warning-footer tests — 2 passed.
- Focused strict restore reconciliation test — 1 passed.
- `npm run build` — passed.
- `npm run lint` — passed.
- `git diff --check` — passed.
The full daemon/repository suites were attempted on Windows but include unrelated existing environment-dependent failures, including Unix shell-path assumptions, temporary repository path handling, missing native `better-sqlite3` build prerequisites, and fixture/package guard failures.
## Anything you were unsure about
Upstream GitHub Actions still needs its normal maintainer approval for this fork's workflow run; local verification is green for the changed paths.
`docs/as-built/cli-reference.md` said it was verified against `92a2b8f7`. This regenerates its command tables and counts from the Commander tree returned by `createProgram()` at `7923e13e`, the file's own method.
**Counts:** 90 top-level registrations, 399 registered command objects and 1,255 option objects (previously 87, 356 and 1,084).
**What changed in the tables:**
- New rows: `rig seat set-terminal-policy`, `rig seat set-effort`, `rig seat effort`, `rig seat restart`.
- New options: `rig daemon start --allow-db-conflict`, `rig send --type-without-checking`, `rig queue transitions --full`, and `rig seat launch --plan`. `rig seat launch --reason` is no longer required.
- `rig gateway human show` and `set` now appear in registration order.
The hand-written notes under the tables and inside option cells are kept. Each was checked against the current source.
**How it was checked:** every table row was parsed back and compared with the walked tree, and all 399 rows and their options match. The same walk at `92a2b8f7` reproduces that version's 87 top-level registrations and 356 command objects.
Docs only. No source, test or other doc changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What this changes
A cross-host `rig queue create`, `handoff` or `handoff-and-complete` goes to the selected daemon, which forwards it to the remote daemon with a 10 s deadline (`QUEUE_FORWARD_TIMEOUT_MS`). When the remote is slow, two things made the write read as failed even though the row could still land.
1. **The daemon's forward timeout is now an unknown outcome** (`routes/queue.ts`).
- **Before:** it answered 502 `remote_queue_write_failed` with `failureClass: "unreachable"` and no outcome. But the write had been sent, so the remote may still commit it.
- **Now:** it also carries `outcome: "indeterminate"`, and the detail says "the remote may still commit it". `failureClass` is unchanged.
- **What the CLI does with it:** it already renders that outcome as "The write outcome is INDETERMINATE … reconcile by ID before any retry", with the request ID and recovery steps.
2. **The CLI waits for the daemon's answer** (`commands/queue.ts`).
- **Before:** it waited only its 5 s client default, so a remote that answered in 5–10 s became a local timeout, though the daemon would have returned the remote's answer.
- **Now:** cross-host queue writes wait 15 s, longer than the forward deadline. Writes to the local daemon keep the default. Cross-host `rig send` already waits longer for the same reason.
3. **The CLI's timeout headline** now reads "queue request got no answer in time (routing to host '…')" instead of "queue transport failure". The INDETERMINATE line and the recovery ID still follow.
## Why
A caller told "failed" (or "unreachable") retries, and a retry without the original ID creates a second row once the first one lands. A timed-out cross-host write should read as unknown, with the ID to reconcile by, which is what the CLI already does for its own lost connections.
## Tests
- **Fail-first:** with these tests on the unchanged source, the new daemon case and four CLI cases fail. The local-default cases pass before and after.
- **New daemon case** (`queue-cross-host-create.test.ts`): the origin hasn't answered when the 10 s deadline passes (fake timers). It returns 502 `unreachable` with `outcome: "indeterminate"`, and nothing is written locally.
- **New CLI file** (`queue-cross-host-write-wait.test.ts`):
- cross-host create, handoff and handoff-and-complete pass `timeoutMs: 15000`, and local ones pass no override;
- a CLI timeout reads "no answer in time" and INDETERMINATE, with the `--id` to reconcile by.
- **Runs:**
- daemon `queue-cross-host-create` and `queue-cross-host-handoff`: 70 passed;
- CLI `queue-cross-host-write-wait`, `queue-host-refusal-hint`, `d14-loud-queue-transport-failure` and `queue-write-receipt`: 120 passed;
- the daemon build and CLI `tsc --noEmit` are clean.
- **Not run:** `queue-host-qualifier-route` (it spawns the built CLI), the full suite, and a real slow remote.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What and why
`rig up` could report a seat **launched** (status `completed`, exit 0) when tmux had made no session at all. The only signs were follow-on
warnings such as `error connecting to <socket> (No such file or directory)`.
- **The cause:** tmux can exit 0 having made nothing. When the server can't create its socket, it prints
`error creating <socket> (Permission denied)` and quits, and the client still exits 0. That happens, for example, when the socket's parent
directory exists but this user can't write to it. `createSession` treated exit 0 as created.
- **The change:** `new-session -P -F '#{pane_id}'` prints the pane it made. When it prints none, `createSession` reads the session back
with `has-session`, and only a found session keeps the launch.
- **A positive "can't find session"** fails the launch as `session_not_created`.
- **A read-back that can't reach or read tmux** (no server, a socket error, an unexpected probe failure) fails it as
`session_unconfirmed`: "tmux returned exit status 0 without a usable pane id; creation of session … could not be confirmed (…)". The
message carries tmux's own read-back error and says what to check: the `TMUX_TMPDIR` directory, or the directory of an explicit `-S`
socket path, must be writable by this user. `probeSession` itself documents a no-server answer as "not determined", so it isn't
reported as absence.
- **What's preserved:** normal pane output takes the unchanged path, and a launch that prints no pane but whose session is found still goes
ahead.
## Measured (tmux 3.6a; private sockets, sentinel names)
| Command, with the socket's parent present and mode 0500 | Exit | Output |
|---|---|---|
| direct `new-session -P -F '#{pane_id}'` | 0 | `error creating … (Permission denied)` on stderr, no pane id |
| `has-session` | 1 | `error connecting to … (No such file or directory)` |
| the same `new-session` with an unwritable `TMUX_TMPDIR` | 1 | (the client can't create the directory, so main already reports failed) |
Another platform showed the same with tmux 3.2a.
## Notes
- **The shared classifier is unchanged.** `probeSession`'s transport classifier doesn't recognise an explicit `-S` path's error (the message
starts with `Command failed:` and names no `tmux-<uid>` path). So the read-back uses tmux's own stderr directly rather than widening a
classifier every caller shares.
- **On top of #1907:** main (`e3ee9e0c`, #1907) is merged in. The read-back follows #1907's `runEnvironmentCommand`, so it covers its
`start-server ; source-file` chain too, which exits 0 the same way.
## Cost (net vs main `e3ee9e0c`)
| | Lines |
|---|---|
| Source | +28 in `tmux.ts` |
| Tests | a new file, `tmux-create-readback.test.ts`, +78 |
| Adapted stand-ins | 6 one-line changes, so `new-session` prints a pane id as real tmux does; their pinned command lists are unchanged |
- **New codes:** `session_not_created` and `session_unconfirmed`, only where no session was confirmed.
- **New refusals or settings:** none.
## Proof
**After the main merge and review50-r2's N1** (head `e8e5805a`; merge `89a20fc7`, then the N1 commit):
- **RED:** the pre-N1 read-back with the N1 tests fails 3: unconfirmed versus not-created, and the real tmux case.
- **GREEN:**
- 31 tmux and launch files (599 tests), including #1907's credential and argv files.
- 33 more files that create sessions through a real adapter: 659 pass. 5 Pi native "runner ready" timeouts fail identically on main
`e3ee9e0c`.
- Daemon `tsc` is clean.
- **Not separately run:** the intermediate merge commit; only the head was tested.
**Before the merge:**
- **RED** (`tmux.ts` at main, the new test file): the no-pane, no-server case and the real tmux case both read `ok: true`. Main's file was
restored afterwards and confirmed with `cmp`.
- **GREEN:**
- The new file: 4 tests, including the real tmux case, which is skipped on Windows, without tmux, and as root.
- The adapted `tmux-adapter` and `tmux-argv-exec` files: 158 in total with the new file.
- The other 27 tmux and launch test files pass.
- 33 more files that create sessions through a real adapter: 659 pass. 5 Pi native "runner ready" timeouts fail identically on main
(environment).
- Daemon `tsc` is clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What this changes
The openrig-user skill's "After a queue write" section, and the regenerated skill digest. No code changes.
- **The request ID.** `rig queue create` prints `Queue create request ID: <id> (not proof of persistence)` on stderr *before* it sends the write, so an interrupted create can be reconciled. The section now says:
- that ID names a request, not a row;
- the row is the `qitemId` in the answer on stdout, and a non-zero exit means no row was confirmed;
- don't merge stderr into stdout and keep the first `qitem-…`.
- **Proven refusals.** The section listed every 502 `remote_queue_write_failed` as indeterminate. A 502 whose `failureClass` is `unknown-host`, `unsupported-transport` or `registry` is refused before any host is contacted and writes nothing, which is what the CLI already reports ("No row was created"). `unsupported-transport` means the host entry is SSH-declared; `rig host list` shows its http entry.
## Why
Queue writes to another host go through the selected daemon, which forwards them only to an http-transport registry entry. For an SSH-declared entry it answers 502 `unsupported-transport` before contacting the host, and nothing is written anywhere (`queue-cross-host-create.test.ts`, "ssh host: unsupported-transport (502), NOTHING written locally").
The CLI reports that honestly: the refusal, exit 2 and "No row was created". But a caller that runs `… 2>&1 | grep -oE 'qitem-[0-9a-z-]+' | head -1` keeps the request ID, which is printed first. It drops the refusal, and the pipeline's exit status hides exit 2. The caller then holds an ID for a row that doesn't exist. The skill is where agents learn how to read a queue write, so this is the place to say it.
## Checks
- `node scripts/mirror-skills.mjs --check`, `node scripts/generate-context-packs.mjs --check`, `node scripts/check-docs-guard.mjs` and `node --import tsx scripts/check-first-run-references.mjs`: pass.
- `node --test scripts/mirror-skills.test.mjs`: 34 passed.
- Vitest, `--maxWorkers=2`:
- daemon `openrig-core-plugin`, `queue-cross-host-create` and `queue-cross-host-handoff`: 117 passed;
- CLI `queue-host-refusal-hint`: 26 passed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What and why
An outside audit raised two questions about labels (credited in #1918, from Heretek-AI/heretek-rigs#81 part b).
**1. The stuck sweep's recovery rows look like they came from a person or seat that never sent them.**
- **The cause:** a recovery row's source is the stuck row's creator (`queue-stuck-sweep.ts`, "the obligation's own creator is the finding's source"). `create()` staged the wake with that source as the envelope sender, so the seat saw "From: <creator>" and "↩ Reply: rig send <creator>". That creator never sent it.
- **The change:** a row OpenRig creates itself (`wakeTrigger: "openrig"`) now wakes from `daemon@kernel`, the scheduler's existing sender name. The envelope offers no reply for OpenRig's own senders; it says "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
- **Unchanged:** the row's `source_session`, its admission, and its urgency marking. A source that is already OpenRig's (`daemon@kernel`, `system:health`) keeps its own name.
**2. `identityProvenance: transport:v1` reads as stronger than it is.**
- **What it records:** the label is accurate. It means the name came from the `X-OpenRig-Session` header, which the CLI sets from the seat's `OPENRIG_SESSION_NAME`, rather than from a body claim. The reference docs already say so.
- **What overclaimed:** two user-visible `actor_required` messages said "no authenticated transport identity", and several comments said the header "proved" or "certified" the actor.
- **The change:** those are reworded, and `coordination-primitive.md` gains one sentence saying it isn't authentication.
- **Unchanged:** the label, the stored values, and every refusal.
## Rows that change wake sender
| Creator (`wakeTrigger: "openrig"`) | Row source | Wake sender before | After |
|---|---|---|---|
| stuck sweep finding | stuck row's creator | that creator | `daemon@kernel` |
| workflow exception escalation | instance creator | that creator | `daemon@kernel` |
| health diagnosis, periodic | `system:health` | `system:health` (with a reply line) | `system:health`, no reply line |
| health diagnosis via `rig health diagnosis evaluate --apply` | the seat that ran it | that seat | `daemon@kernel` |
| continuity cutover baton (watchdog) | the registrant | the registrant | `daemon@kernel` |
| mode scheduled end | `daemon@kernel` | `daemon@kernel` (with a reply line) | `daemon@kernel`, no reply line |
**Why the relabel sits in `create()`:** at wake time, trigger `openrig` also covers boot recovery and ladder retries of rows that agents created. Relabeling there would hide real senders; only `create()` knows that OpenRig itself made the row.
**What counts as OpenRig's own sender:** `daemon@kernel` and `system:` actors. Neither can be a seat's name (`{pod}-{member}@{rig}`). `…@system` isn't matched, because a rig may be named `system`.
## Cost
| | Lines |
|---|---|
| Source, net vs main | +42/-29 |
| of which behaviour: `queue-repository.ts` | +6/-3 |
| of which behaviour: `pane-envelope.ts` | +9/-1 |
| of which wording only (7 files) | the rest |
| Tests | +42/-1 |
| Docs | +3/-1 |
- About 1 test line per source line.
- **New refusals:** none. **New error codes:** none. **New settings:** none.
## Proof
- **RED:** the new test files were run against main's three source files (`pane-envelope.ts`, `queue-repository.ts`, `require-sender-identity.ts`), with everything else at this head. 5 cases fail. The recovery-row case received `From: peer@test`; the envelope case received `↩ Reply: rig send daemon@kernel "..."`; both message cases received "no authenticated transport identity"; the held copy was `senderSession: peer@test`. The fixed files were restored and confirmed byte-identical with `cmp`.
- **GREEN, daemon:** 23 files, 911 tests at `--maxWorkers=2`. In the first run, 910 passed and one new case failed on the test's own mistake: a peer's routine wake is held, not typed, at that level. I fixed the test, and its file then passed alone, 21 of 21. The set:
- the four changed test files;
- stuck sweep (both), workflow exception (four), health diagnosis, continuity materializer, mode scheduled end;
- wake ladder, held-wake escalation, parked-owner consumer;
- local-bare-identity, self-host envelope (triple and sweep), session-transport, queue nudge summary, transactional closure, s02-p3-sender.
- **Typecheck:** daemon `tsc` is clean.
- **CLI:** send, send-header, client-headers, self-host-triple: 129 tests.
- **Repo:** `npm run test:repo` 309 pass, 0 fail.
## Base
Built on `26d629e3`. Main has since gained only #1903, which touches `queue-stuck-sweep.ts` and its test; there's no overlap with this PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A malformed structured field on an unrelated row tagged health-diagnosis could prevent diagnosis enumeration. Read only matching IDs for the preliminary census, then read each owned occurrence fresh. Keep exact tag selection, newest-created ordering, and the 10,000-row refusal before filtering for ownership.
The preliminary census intentionally stops producing discarded row projections and their incidental activity effects and errors. Owned validation, custody, and human-delivery receipts retain their existing reads.
Validation: the regression fails on the base; implementation and independent QA each pass all 24 affected tests, and daemon typecheck passes. No production speedup or stall-cure result is claimed.
Extend the installed daemon-restart scenario with restarts that drop inherited TMUX/TMUX_TMPDIR and the test scaffold's forced-socket wrapper. It checks that the same two team seats, processes, sessions and bindings survive, with no new panes on the default socket and no superseded sessions.
The sequence (extended for #1906's option (a)):
- **First blind restart:** the team keeps running.
- **Second blind restart, after a real identity poll records a verdict with no pid:** the team still keeps running.
- **Recovery:** after a restart that can see the seats' server, a fresh sent marker reaches the pane.
- **Gone control:** before the kill it records the owned seat's processes (the pane shell, its child and the recorded pids) and rechecks the pane's identity. It then closes the owned pane with `tmux kill-pane`, since SIGTERM doesn't end an interactive shell. Every recorded pid must answer "No such process", and that seat must detach while the other stays running and untouched.
Scope: team seats under the existing `--no-kernel` helper; kernel duplication isn't exercised. The existing preserve/failed-backup and MCP checks stay in the same case. tmux fields use a literal `:` separator, because tmux without UTF-8 or `TMUX` renders a tab as `_`. Required result checks and negative controls cover missing or replaced seats, changed bindings, superseded sessions, duplicate panes, a retained wrapper, a changed fixture identity and a surviving process.
Validation: 23 local result tests pass (synthetic proofs; the mock renders tabs as tmux does without UTF-8, and models a surviving shell). `installed-scenario` passes in CI at this head on main with #1906 and #1907, with `daemon-restart-preserves-team-identity` PASS. Installed runs on the published baseline and candidate archives remain separate validation. No native or model calls.
Seat launches, handovers and one-off environment cleanup keep provider credentials and the default activity-hook token out of process command lines. Environment-bearing tmux commands use create-only mode-0600 files. tmux-specific quoting preserves multiline values, indentation and comment-like text. Scoped shell resets use builtin exports from a private bootstrap. Managed Claude keeps its existing environment allowlist using builtin exports and removal names, with a short child of the daemon's Node parsing NUL-delimited input under an empty environment.
Validation: synthetic runtime REDs reproduce multiline corruption and managed-Claude argv exposure. The pre-PR tmux byte comparator passes all three native launch modes. Final affected suites and daemon compilation: 33 files / 620 tests pass on macOS with tmux 3.6a; daemon compilation passes. The follow-up startup-suite correction moves five startup tests from the old argv to the private launch config, keeps every value assertion and adds argv-absence checks; 648 affected tests pass. No provider/model calls. Native Windows/psmux and full daemon/repository suites were not run locally.
Cost: 11 files, +313/−102; two production files, nine test files, no dependency or setting. Managed launches add one short Node child and use the platform env utility's -0/-u options. Failed staging stops rather than reverting to credential argv. The scoped bootstrap is single-use; OpenRig restarts and handovers supply a fresh command.
Limits: 0600/wx protects creation; later reads use the pathname and trust a protected temp directory and its parent ancestry. A replaceable, non-sticky parent can permit substitution. Failed/uncertain operations or failed unlink can retain owner-only credential-bearing files; failure cleanup is not complete.
Requires an `env` that supports `-0` and `-u`: GNU coreutils and macOS 15 were checked; busybox and older macOS are unverified.
When the daemon is started without the seats' TMUX (another shell, a service manager, an upgrade script that trims the environment), it looks at a different tmux server and sees none of its seats. Startup used to detach them all, classify the kernel as lost and relaunch the rig, leaving the person's running agents unmanaged beside new duplicates.
A session that isn't visible is now kept when the seat's last observed process for that same session name is still running and started no later than that observation. It's reported in ReconcileResult.unseen with the process's immediate parent, the kernel classifies as live, and startup logs one warning per seat naming the process, its parent's command line and the fix (restart the daemon inside the seats' tmux server, or with TMUX set to it).
Migration 135 adds evidence_pid, evidence_pane_pid and evidence_at to seat_identity_verdicts, backfilled from verdicts that carry a pid. The store's upsert refreshes the evidence from any verdict that observes a process, keeps it through verdicts without one for the same session, and clears it for another session. The current verdict is unchanged, so the restarted daemon's own blind poll records an honest null-pid verdict without erasing the evidence, and a second blind restart holds too.
Tests: unit (injected process table: running process, server without the session, reboot, pid reuse, unreadable table, earlier session, second restart after a real null-pid poll, recovery on the correct server), native (a real tmux server on its own -S socket and a daemon environment with no TMUX), store evidence (kept, refreshed, cleared, backfilled) and a kernel-lost-restore case. RED on the base and at 95ce152f (exactly the two second-restart cases); GREEN at 01d6752c on every CI job.
Limits: the pid check is a conservative hint, not process identity (whole-second start times). Evidence is only the last observed process. A surviving orphan can keep a dead agent's session running until a later startup or recovery. One retained kernel session suppresses automatic lost-kernel restoration. A daemon on the wrong server still can't operate those seats until it's restarted where it can see them.
A stuck row addressed to a removed rig could abort the sweep before unrelated rows received recovery findings. Isolate each finding create so later candidates continue, keep still-detected conditions unresolved, and report the pass as failed with its acknowledged partial progress.
Validation: the base fails the later-valid-recovery fixture; implementation and independent QA each pass 132 focused tests, and daemon typecheck passes. Existing custody and dedup controls pass. The local repository fixture does not establish production incident causality. Known diagnostic limit: a create error after persistence can still be labelled 'not created'; existing recovery lookup can find the persisted row on a later pass.
Cost: source +17/-2 (1 file), tests +33 (1 file). No new API, refusal or routing fallback.
A typed Slack reply absorbed into an existing button or Steering answer stays absorbed when Slack replays it after an interrupted close and final seen append. The replay returns the original answer instead of sending words the person was told were not sent. A different later event remains conversation.
Record the absorbed event, ask, person and original answer in the existing seen log before closing the ask. Keep the final event marker after close so an interrupted close can still finish. Cost: one extra durable log record for an absorbed event; no schema change.
Validation: both button and Steering cases reproduced the duplicate before the fix; all 21 regression cases and 166 affected tests pass, as do daemon compilation and TUI typecheck. Synthetic migrated SQLite and real router/answer routes with recorded transport nudges; no live Slack or model execution.
Follows #1896, merged as dacd498e. This change is the delta after c9c7344d.
Updating and then stopping watchdog jobs left their replaced schedule YAML retained for the repository lifetime. Release that history after completed owned delivery accounting, or successful retirement with no owned delivery. Live delivery pins, durable records, receipt order and unaccounted retry state stay intact. The existing eight-plus-pinned-start bound is unchanged.
Validation: the stop-retention case fails on the base; 148 focused tests passed in both implementation and independent QA runs, and daemon typecheck passed. Map assertions observe stop and held-completion release. Terminal/generation disposal is supported by source review and existing behavior controls, not a dedicated new disposal assertion. The reviewed contract is the existing serial scheduler; arbitrary overlapping direct evaluations remain outside it.
Cost: source +13/-1 (1 file), tests +33 (1 file). No new API, refusal, error code or timer.
A terminal member, a seat that runs a plain shell or program instead of an agent, now gets a deliberate rig send's exact text. The agent-style From/To envelope, and an urgent send's 'Urgent (marked by ...)' line, would otherwise be typed into the shell and run. The urgent mark still admits the input and stays in the result.
A queue reminder to a terminal member types nothing; the task stays queued with a terminal-seat reason instead of being typed into the shell.
OpenRig's own notices carry an internal marker. Holding them for terminal members by default is the seat interruption levels' work, not this change. Explicit --dangerously-interact sends are unchanged and still read the composer first.
An optional stop for two OpenRig daemons sharing one database. With daemon.db_conflict=stop, rig daemon start stops before the database opens when another running OpenRig is confirmed to hold it (an open-file holder, a matching daemon.json and listener PID), names the reason, and offers a one-start go-ahead: rig daemon start --allow-db-conflict. The default stays warn, with today's warning text; an explicitly selected database outside OPENRIG_HOME alone only warns, and an unknown holder never stops.
The go-ahead flag applies only to that start's child and isn't saved. An exported OPENRIG_ALLOW_DB_CONFLICT=1 still affects later starts from that shell, and an inherited policy value overrides the file; the docs say so. daemon.db_conflict is read at boot, and rig config set notes that.
Retrying an answer from Slack or Steering/TUI after an interrupted close now reuses the first committed reply, finishes the request once, and names where the original answer went. Before, the two surfaces could each create a reply and wake the asker twice.
While an ask is still pending, the first committed answer wins and a second typed message is treated as a retry. Once it closes, a later typed Slack message is ordinary conversation again, and a replay of the typed event that produced the answer reuses it. Legacy reply ids and attribution stay readable; wrong-person replies stay correspondence; reactions and recovered late messages stay separate. No schema, setting or error code is added.
One narrower case remains for a follow-up: if a button or Steering answer's close is interrupted, a typed retry then completes it, and that retry's seen record is also lost, a replay of the typed retry can still arrive as a second reply.
Stream retention no longer deletes history that existed before the upgrade. Migration 131 records a floor, the newest stream item present when it runs, and both the age prune (30 days) and the 20,000-item cap apply only to items above it. A fresh install has no floor and prunes as designed. This corrects #1891, whose first sweep after upgrade would have deleted existing unreferenced items older than 30 days.
Referenced-item protection, the gap state, the 410 for an expired list cursor and the batch bounds are unchanged. No new setting, refusal or error code.
An explicit rig send --dangerously-interact no longer types over text a person has already typed. Before pasting, it reads the target's composer under the existing lease and final target check, on every override path, including a broadcast that waits for idle. Typed or pasted text stops the send, and the caller is shown that text, apart from any suggestion (override_input_present). Input that can't be read stops with its reason (override_input_unknown) and is never treated as empty. An empty box, or one holding only a suggestion, types as before, and a recognised native prompt is still driven.
--type-without-checking skips the read for one send, says so, and clears nothing. To clear on purpose, use the native or tmux controls and send again; that clear happens outside OpenRig's checks and isn't recorded. Reading and pasting aren't atomic: a keystroke can land in between. The shown text goes only to the caller, never to the receipt, outbox, held messages, capture observer or audit.
Ordinary sends, notices, wakes and the guard-off default are unchanged. The openrig-user skill now teaches the stop and the deliberate clear.
rig project wake --decide now finishes work that was already accepted even when decisions are turned off: it applies accepted-but-unapplied effects under the classifier lease with no model call, so an accepted create or join never stays at effect_pending. Without the lease or a daemon it says so and leaves the effects waiting.
The automatic path skips items that intake already abstained on under another version, except experiment versions; this needs the updated daemon (an older daemon ignores the new parameter). rig project wake --experiment stays and records its attempts as <version>+experiment, so rig stream show labels them experiment and they never block --decide. The kernel queue worker's guidance and the reference doc now describe the --decide path and the disable boundary.
Reader seats can follow the stream without reading it all. rig stream subscribe <seat> saves a filter (by default, items addressed to the reader or tagged with its session, seat or logical id; --tags and --type override it) and a cursor the daemon keeps across compactions and handovers. rig stream read returns what's new after that cursor as a bounded page: at most 20 items, 2,000-character excerpts, and the whole serialized page under 64 KiB, with clipped metadata marked. If retention pruned past the cursor, the page says so instead of looking complete. rig stream subscriptions lists each reader's unread count and size.
A body over 16 KiB is stored truncated with a marker, and its kept and original sizes are shown by read, show and list. Nothing is refused.
Stream retention keeps at most 30 days or 20,000 unreferenced items (retention.stream_days and retention.stream_max_items); items with a classification, attempt or disposition are kept. Upgrade effect: retention is on by default, so the first sweep after upgrade deletes existing unreferenced items older than 30 days, and a downgrade can't restore them. A rig stream list --after cursor whose anchor was pruned now returns 410 stream_cursor_expired with the pruned position, instead of an empty list.
Migration 130 adds the subscription and retention-mark tables and two nullable size columns on stream items.
rig setup --providers now saves the choice as provider.selected (claude, codex, or claude,codex; empty when no choice was recorded), and kernel boot honours it: a provider the person didn't choose is treated as unavailable before the team is picked. Before this the choice wasn't saved, so the kernel picked its team from the sign-in check alone, and a signed-in but exhausted Claude account gave the person Claude seats.
With no choice recorded, kernel boot works as before. A kernel that already exists keeps its seats; setup says so, and says when OPENRIG_PROVIDER_SELECTED in its environment takes precedence. The kernel operator's provider step reads rig config get provider.selected first.
One new stop: with only Codex chosen, Codex signed out and Claude signed in (or the reverse), kernel boot stops with auth_blocked, naming the choice and its login command and saying to rerun setup with --providers to change it, instead of starting a kernel on the runtime the person didn't pick.
rig stream show <id> now says what became of an observation, derived each time from existing records: its disposition (from rig project wake --decide), its classification, and the linked queue row.
The stages are awaiting intake, deferred to intake, decided by intake, no action, duplicate, effect pending, with intake, work open and work closed. A handed-off or superseded row is followed to its live successor (up to 8 hops); a failed read or an unlinked handoff reads unknown, never closed. A closed row shows its closure, resolution and evidence, with the note that closing isn't proof a fix shipped. Against an older daemon the outcome says the records aren't there.
Read-only; the one daemon change is an additive streamItemId filter on the existing classification list.
A consistency fix. When the managed-compaction preparation prompt's send result says inputPhase "not_reached" (nothing reached the seat), the enforcer now treats it as not sent, as the /compact send already does. An automatic attempt goes back to sending, bounded by PREP_SEND_ATTEMPTS_MAX, and a manual request stops at once with the hold's reason, instead of waiting out the 25-minute deadline as uncertain.
On a real draft-aware seat the held prompt was already retained and not sent on main; a new control case pins that. This change covers a raw not_reached result reaching the enforcer without draft-aware delivery.
A release-preparation check that changed bundled hooks and skills will reach existing installs. Managed refresh replaces installed plugin files only when the bundled version is strictly newer, so changed files at an unchanged version silently stay as they were. Run node scripts/check-packaged-plugin-versions.mjs candidate.tgz on the exact release-candidate archive: it compares the packaged plugins with the last release's (additions, removals, bytes, leaf-link targets), requires a strictly higher numeric version when anything changed, requires consumed manifests to be regular files and harness versions to agree.
The baseline is published 0.7.0's 53 plugin files. --snapshot refreshes it from a newly published archive, writing to a temporary file first so a failed run keeps the old baseline. The check isn't called from per-PR CI, so contributors don't bump plugin versions for individual edits. It runs on our own release-candidate archive and isn't hardened against a crafted one.
Docs only. ROADMAP.md gains a "Planned for 0.7.1" section: team skills through one router that stay out of your project; per-seat interruption levels with presets for managed compaction and model or effort changes; each seat's planned and current permissions; previewing and installing bundles that run services, off by default; subscriptions, if ready; and community contributions to launch, lifecycle, runtimes, observation and delivery.
Test-only. The transport-wait cases in claude-warning-footer-readiness.test.ts injected a fixed hook clock and a no-op sleep, but waitForIdle used Date.now and a real timeout, so under runner load a valid running observation could miss its 20 ms window and read no_observation_in_time. It failed one daemon CI run on 2026-10-23.
The waits now use a fake Date and setTimeout advanced only by the injected clock, so the result no longer depends on machine load. Every case keeps its assertion. A forced 30 ms delay in fixture capture fails the old fixture and passes with the fix; the whole file passes.
Docs only. cli-reference.md and openrig-user's SKILL.md now describe what 0.7.1 adds or changes, as the code does it:
- gateway human slack-subscription: set every-ask, urgent-only or unset; show prints the value and whether it's authored or the default.
- ps --nodes --json: the compact projection keeps the served needsAttention; arbitratedNeedsInput and needsInputEvidence come with --full or --fields.
- compact-plan: its flags are the plan's own thresholds, and the output shows policies.claude_compaction separately.
- The send guard at a complete, current Claude MCP approval menu: the send refuses with target_needs_input, and --dangerously-interact --reason is the only override.
- Startup attention: after a refused Claude startup turn, later inputs are held and counted; Cursor and other community-harness assisted startup is reported honestly.
The skill's MCP section also stops overstating three things: a missing or unreadable grant file is read again on every request and recovers without a restart; --owner offers every tool, not only --workspace; and several tools beyond the queue tools and rig_whoami return structured results.
Three files, +26/-5, including the skill's one digest entry. The PR body cites each change to source at the base and lists what was left unchanged and why.
When the typing guard holds a send because a person is drafting, the send's result now says nothing reached the seat: inputPhase "not_reached", as a managed-compaction hold already did. The guard's draft checks all run before the paste, so no target input is attempted. The reason, error text, sent: false and every guard decision are unchanged; a generic failure stays "attempted", and a submit refused after a successful paste stays "written" with sent: true. Callers such as the identity-hint warning can now say "not typed" for these refusals rather than "could not establish".
The compaction enforcer read not_reached as "re-arm and retry". It now excludes draft refusals narrowly, so a draft hold of the managed /compact still stops as not sent and starts fresh after its cooldown, as before. Genuine idle deferral, including the reason-independent control, is unchanged. Cases cover busy, unknown and changed draft refusals and a refusal clearing on the next poll before cooldown.
rig project wake --decide: during the intake seat's own wake, each eligible stream observation gets one bounded typed decision (#1879's component and the shipped stream template), and routine cases reach owned work without a full intake turn. Decisions stay off unless enabled.
- create opens the chosen supplied owner's row with a derived id; join adds an association note (not a wake or reopen) to supplied work; duplicate (which needs supplied work) and no_action are recorded with no queue effect.
- Anything uncertain, refused, malformed, stale, failed or not called is deferred to intake as its own disposition, with the request, template and returned provenance; the agent can classify it at once, and the model isn't asked again. Only a confirmed missing disposition permits a new call.
- Accepted effects keep their accepted source and fixed id; recovery applies them without new inference, and a different holder reconciles a matching row or hands the item to intake. Application carries the exact lease and occupant through each queue write, via an optional classificationBinding that ordinary queue calls don't carry.
- Migration 129 adds stream_dispositions, one row per stream item, inserted with the classification.
New refusals, all on this opt-in path: invalid_disposition (400), disposition_not_found (404), disposition_exists (409), decide_usage (400), invalid_classification_binding (400); stale bound writes reuse the existing lease and occupant 409s. Known limit: two concurrent wakes from the same valid lease can append a join note twice.
Test-only. The refocus content-ref fallback case asserted that both optional lookups run, but at the default 4500 ms budget the hook skips them once process uptime passes about 250 ms, which a loaded CI runner reaches. That skip is the hook's designed degradation, so the case failed at random (one daemon CI run on 2026-10-23).
The case now pins OPENRIG_REFOCUS_BUDGET_MS to 52000 ms, the supported maximum, so it tests the fallback logic rather than runner load. This is a test budget pin, not a race fix. The four-verb assertion is unchanged, and no product default changes. An injected delay at hook start fails the case on the old env and passes with the pin.
The claim identity-hint warning said "not typed" for every failed transport result. The transport only establishes that nothing was written when inputPhase is not_reached: attempted may have landed, written text may sit unsubmitted, and a result without a phase doesn't say.
This changes only the warning text in ClaimService.deliverClaimHint. not_reached keeps "identity hint not typed into <session>"; written (or sent: true) says the text was written and the submit isn't confirmed; attempted says it may have been typed; no phase says it couldn't be established. The transport's reason and error are still included. Delivery, guards, hint routing and transport outcomes are unchanged. Four cases cover the evidence states; the three non-not_reached cases fail on the old wording.
Known limit: pre-write refusals that return sent: false with no inputPhase now read "could not establish" rather than "not typed"; giving those transport paths inputPhase: not_reached is separate work.
A reusable decisions component in packages/cli/src/decisions/, the first increment of the stream-routing work. It's off by default and has no production caller yet.
A caller passes a named template (instructions plus a typed answer contract) and bounded state, and gets one result: answered, abstained, refused, malformed, stale, failed or not_called. A provider error is failed, never an abstention; off, unconfigured, an unknown consumer or a missing key make no call. One run owns the call count, total deadline and concurrency, rechecks its settings and template before every send, and halts after a timed-out or cancelled call that may still be running. Every sent result carries the exact request (never the key) and the template's text and digest; returned provenance is kept on failures too.
The key comes only from OPENROUTER_API_KEY in the invoking process and goes only in the Authorization header. Settings: decisions.enabled (default false), decisions.consumers and decisions.template_dir (absolute only). A stream template ships with it. rig project decisions status shows the state without making a call. The call copies the existing experimental Jev client rather than reusing it; the two converge when that path moves onto this component.
Every ask to a person is on Steering and the TUI. Whether it also posts to Slack is the person's Slack subscription, the first attention subscription: a saved filter plus a destination on their gateway record (subscriptions: [{ destination: { connector: slack }, filter: { preset } }]).
- every-ask, the default: every ask posts to Slack, as on 0.7.0. Nothing changes on upgrade.
- urgent-only: only an ask marked --alert posts; the rest wait on Steering and the TUI (#1868's behaviour).
Set it with rig gateway human set <id> slack-subscription every-ask|urgent-only|unset; rig gateway human show prints it and whether it's authored or the default. --alert decides per item: a handoff doesn't inherit another ask's alert, and rig workflow route keeps a person's alert-worthy failed-step approval alert-worthy. The record's keys are closed and the preset must be a plain string, so a malformed entry is refused instead of silently quieting Slack. The skill, --alert help, the MCP text, --verify's not-alerted wording, the Feed and the Slack setup page use the subscription framing. A filter language, channel destinations and UI come later from the same record.
Each seat's attention now comes straight from the nodes list, decided the way the rig's attention total decides it, instead of each reader copying its own rule.
- The nodes list and detail serve needsAttention from the same predicate as the rig's attentionCount, plus arbitratedNeedsInput (true, false or null) and needsInputEvidence (rung and time, or null). The existing predicate moves from ps-projection to the inventory, with the old exports kept. No new endpoint, probe or arbitration engine.
- The CLI drops its copied rule. Compact human output adds an ATTN column (yes, no or ?) before REASON, so REASON now starts at column 104 instead of 99 and is the seventh field; compact JSON keeps the served flag, and full output shows the provenance.
- The TUI's hydration, graph glyphs and host attention totals use the served flag, and the seat detail shows the evidence on its activity line, labelled by its actual source: hook, pane or unknown. A hook-only prompt reads needs_input.
- Mixed versions: when an older daemon doesn't serve the flag, it reads as unknown, not false. The TUI then keeps its previous status and glyph from the older fields, and compact CLI keeps the diagnostics (latestError, the activity reason) that daemon already serves.
0.7.1 slice 06 outcome C. A false result means no known trigger, not proof that no unobserved input is waiting.
Test-only. Three manual-path tests in claude-compaction-enforcer.test.ts hold the first preparation open across later calls: the concurrent same-seat guard, the state-surfacing test and the DEGRADED duplicate test. Their fixture gives a manual preparation 50 ms on the real clock, so on a busy CI runner the held attempt could pass its deadline and be stopped as preparation_incomplete, and the test then saw no /compact. It failed one daemon CI run on 2026-10-23 and once earlier.
Each now injects a fixed now(), as the file's wait-for-idle ordering test already does ("busy CI scheduling must not consume its 50ms budget"). The fixture's default clock is unchanged, since other tests rely on a real deadline expiring. Shown by stretching the hold (80 ms: fails before, passes after) and repeated runs.
Test-only. The subscription-move routes test ("reads reports newest first, chains follow-ups...") could fail when two items were created in the same millisecond: queue list() orders by creation time with no tiebreaker, and SQLite then returns a same-millisecond pair in insertion order, oldest first, while the test expected newest first. It failed one daemon CI run on 2026-10-23.
A nextMillisecond() helper now waits for the clock to move on before the later item of each pair (the second filed report, and the second hand-made unreadable item), so the newest-first assertions keep their meaning. The product's order is unchanged; follow-ups chain by id, not position. Shown by forcing the tie (Date frozen: fails before, passes after) and 20 unchanged copies passing after.
docs/as-built/cli-reference.md, the reference agents are pointed to for every command and flag, had fallen behind the registered CLI. This adds or corrects the rows for 13 command and flag groups, each checked against its source line:
- rig community-harness (list --json);
- the rig gateway human quiet group (set with --daily, --until, --tz, --actor, --json; end and clear; show);
- bundle create --include-login-files; down --yes; slack channel-map set --inbound <seat>; capture --history-plus-pane; skill loadout --resend; mode unset --category; whoami --all-peers; provider status --all-hosts and --hosts; rig usage cost (--seat, --json); proof show and proof judge --project;
- mcp token add --seat is marked required.
Docs only. The seat, handover and queue sections, the frontmatter stamp and the counts snapshot are unchanged. Built as cloud batch 2 task D-1.
An ordinary question, approval or request to a person lands on Steering and the TUI's Feed, where they can see and answer it, and nothing pings them on Slack. rig queue create --alert (and rig queue handoff --alert) marks an ask alert-worthy: something only they can do, or an approval whose wait costs now. That ask is the same item, still on Steering, and Slack posts it once. An answer closes the ask wherever it arrives.
- Classification at create: human-required at RECORD for an ask on Steering, at ALERT with humanAlert: true. Nothing re-decides the level later.
- Slack posts a human-required ask only at ALERT, whatever its posting dial says; the dial still governs updates and notices.
- One attention membership for every reader (the Feed, ?attention=1 and the attention aggregate), independent of delivery, with rerouted asks leaving all of them. The Feed lists an ask's questions and answers, and its Slack line shows the actual receipt.
- The TUI answers an ask the way its asker framed it: Approve for a plain ask; each question with its options, with no Approve while a question is open; or reply <text>. The answer is recorded once on the same row through the existing answer path and returned to the asking seat, as a Slack answer is. A completed answer set whose return was interrupted resumes that return; a retry in different words never closes on words that weren't returned.
- The stuck sweep, wake ladder and waiting view treat a quiet ask as waiting on the person; a quiet handoff isn't escalated to Slack. rig queue create --verify reports not-alerted at once.
- Callers that need an alert set it: host pairing, the health notify path, workflow exceptions routed to a person, and MCP rig_queue_create's optional alert. The messaging-the-human skill teaches both routes.
Limits: per-person preferences beyond quiet hours aren't built, and where a mission-control bearer is configured that differs from the TUI's terminal token, the TUI's answers get the existing refusal.
A preflight (for example rig up --plan) resolved each member's profile in turn, and each resolution could run the managed skill-catalog scan and, when a named skill wasn't found in its own folder, the full scan of every SKILL.md. So a plan with N members repeated the same file reads N times.
- Within one preflight run, the managed catalog scan and the full skill-scan fallback each run at most once, and every member reuses that raw scan. Which skills resolve, their precedence, rejections and errors are unchanged, and calls outside a preflight run behave as before.
- The named-skill lookup stays fresh per member, so a skill repaired during the run is still seen by later members.
- A transient read failure in a reused scan is cached for that one preflight run only.
- The resolution worker's comment now states this per-preflight reuse instead of fresh filesystem reads for every member.
The benefit wasn't measured. Refs #1653, which stays open for the reporter's rerun. Built as cloud batch 2 task B2-1.
The openrig-user skill's MCP section listed the old 23 tools and rig mcp serve [--port], so agents learning the MCP surface from the skill didn't know most of it existed. It now matches the shipped server:
- all 34 tools, grouped by access, including whoami, restore status, team, slices, mission, context, artifact read, the decision room and decide, and revises, alongside the queue tools;
- rig mcp serve over stdio, and rig mcp serve --http with scoped tokens (rig mcp token add --seat);
- what an agent with no shell can and can't do, and that nothing wakes an MCP-only agent.
Text only. The skill's entry in scripts/skill-edge-digests.generated.json is regenerated for the new bytes.
A draft-aware send can submit its own paste when the collapsed paste label renders after the first observation. An already-recorded label stays authoritative; the new fallback runs only if none was recorded, within the same send and the original composer. Pre-key guard refusals now say Enter was not sent. Refs #14.
- The mechanism (reproduced deterministically with the real guard, tmux adapter, transport and SQLite): right after the paste the composer still reads empty; Claude's collapsed label appears only at the guarded Enter; the old guard refused before sending the key, and later sends then met draft_input_busy.
- Unchanged and still held: a person's genuine draft, a typed edit, a different or same-count replacement paste once a label was recorded, a cursor or frame change, a different target, a later send, and a native Enter whose result is uncertain. There's no repaste, extra Enter, new tmux call or replay.
This proves one failure mechanism, not the reporter's exact native cause. Already-stranded text and uncertain Enter results remain unreplayed, so #14 stays open. If a person replaces the paste before any label is seen, frame and line count don't prove identity; that ambiguity remains. Cost: source +10/-4, tests +73.
A Cursor seat whose startup text landed in a collapsed composer no longer reads as ready. It reads attention_required, with guidance to check the composer, submit the staged text if needed, and acknowledge.
- startup_assisted (a new optional preset field, default false; the Cursor example sets it): when that seat's startup sends text, startup ends on the existing attention_required status instead of ready, even if the first Enter worked. The seat keeps running, sends still deliver, and a continuation never pastes the text again.
- The acknowledgement is rig seat clear-attention <seat> --reason ..., and it clears this assisted-startup flag after a full restore too, for that seat's own assisted startup only; Claude and Codex seats keep the strict native identity check. Nothing clears without --reason.
- The launch-time value is kept, so a preset changed or unreadable later can't flip the seat back to ready.
- Every harness without the field behaves as before. An older daemon refuses a preset carrying the new field, as with the existing launch_result and names_conversation fields.
- Docs: the Cursor preset section says unattended startup submission isn't confirmed, a new "Assisted startup" section says what the person does, and a line says an unknown sender header doesn't mean the Cursor seat lost its identity.
Unattended Cursor startup isn't established; native acceptance is a separate check. Cost: source net about +20 in round 1 plus the full-restore fix, tests about 2 lines per source line.
A Claude seat whose startup turn is refused (an expired login, say) no longer burns the rest of its startup context.
- Nothing more is typed after the refused turn. Between turns the start now reads the runtime's readiness, and a refusal holds every later startup input.
- The start reports the context as owed, never as delivered: the runtime's reason, how many later inputs weren't sent, and what recovers it. The failure carries pending work, so a later reconcile can't mark the seat ready without its context. A refused last or only turn of a fresh Claude start is held the same way, and a later file error can't replace the hold.
- Recovery: a fresh start that typed no one-shot (non-idempotent) action keeps its context pending, and after signing in, rig seat continue <seat> delivers the whole startup again, in order. Where a one-shot may have reached the seat (typed, even if its Enter failed), or a restore fell back to a new conversation, continuing isn't offered and the start names the fresh launch instead. A resumed or forked seat keeps its conversation: the start says it hasn't read the held inputs, with no relaunch and no continue.
- Startups that aren't refused send the same inputs in the same order.
Cost: source +62/-7 before round 2's few lines, tests about 2 lines per source line; no new refusal, error code or state.
Upgrading OpenRig restarts its daemon. A restart in the middle of a Claude seat's managed compaction could lose its restore (the seat sat idle, holding its work, until someone restored it by hand) or start a second preparation. Now a restarted daemon picks the compaction up where it stopped.
- A record beside each attempt's map (COMPACTION-STATE.json), written atomically by the daemon at every step: the phase (preparing, one of the three post-compact stages, compact_uncertain, done or ended) plus what's needed to rebuild the attempt. Only a record in exactly this daemon's shape is read back, so a newer daemon's record after a rollback can't hold up a seat.
- Recovery, the first time a restarted daemon reaches a seat (the monitor's poll, a manual rig compact, or rig compact --state): an owed stage resumes at that stage; an automatic preparation is waited on with its recorded deadline; a preparation prompt or /compact that was being written is never sent again, and its restore steps are owed; a manual preparation stops as daemon_restarted. It acts only on a record an earlier process wrote, for an unchanged occupant.
- Without a restart, a /compact whose result was lost now owes its restore steps too.
- After a /compact whose outcome is unknown, each restore step goes only into an input box with nothing typed; otherwise it waits, shown as held.
- rig compact --state shows owedRestore from the first look after a restart, and a record older than six hours as stranded, not idle. A later compaction names the record it replaced.
Limits, in the PR body: recovery waits for the first poll, request or state read; a restart between a stage's send and its record can repeat that one prompt; the input box is read once, before the send's idle wait, not at the write; the box reading is a heuristic; a replaced stranded record is remembered in memory only.
rig compact-plan said its own fixed triage thresholds directly under the policy line, with no hint that they aren't the automatic compaction setting. Now:
- The human output labels the plan's thresholds as the plan's own; policy.thresholdTokens and policy.thresholdPercent keep their names and meaning.
- It reads policies.claude_compaction.enabled and its threshold, the same setting the daemon's automatic compaction uses, and says whether automatic compaction is on or off, for example "Automatic compaction: off (its threshold setting policies.claude_compaction.threshold_percent is 80%)".
- When the config can't be read, the plan still prints: JSON carries automaticCompactionEnabled and automaticCompactionThresholdPercent as null with automaticCompactionUnavailableReason, and the human output says it couldn't be read and to run rig config. A missing config file still shows the real defaults.
Candidate selection, the plan's thresholds and its read-only behaviour are unchanged. Contribution by @taljeri, with thanks also to @Totopo27, whose #1851 proposed the same fix in parallel.
When a fresh Claude UserPromptSubmit hook reports running, rig send checks current dialogue and composer evidence before sending. A current MCP server-enablement checkbox menu, an AskUserQuestion choice or a permission prompt blocks the send with the existing target_needs_input refusal. Numbered lines left in the pane's history don't block an ordinary send to a working agent, which keeps the busy advisory.
- The current-dialogue check applies only inside the existing fresh running-hook window for claude-code. Stale hooks, idle or unknown hooks, other runtimes and generic probes are unchanged.
- Protected drafts, --dangerously-interact --reason, and the advisory when activity is unknown or the capture fails are unchanged.
- No new refusal, error code or matcher.
Refs #1541. Contribution by @Nikhi00718.
A Hermes seat started from the example preset can run rig as itself, for example to reply with rig send, once its Hermes config.yaml carries one block.
Hermes runs every tool command in one gateway per machine, with that gateway's environment, and swaps in a session's own values only for its toolchain names and the names listed in terminal.client_env_passthrough. OPENRIG_SESSION_NAME isn't a toolchain name, so without the block rig inside Hermes runs as whichever seat's pane started the gateway, or as no seat.
- community-harness-examples/hermes/config.yaml.example: the block to merge into each seat's config.yaml, listing the seat's own OpenRig variables (OPENRIG_SESSION_NAME, OPENRIG_NODE_ID, OPENRIG_HOME, OPENRIG_URL, OPENRIG_HOST, OPENRIG_PORT, OPENRIG_DB). None is a secret. As a .example, a preset folder never reads it as a preset.
- The example launcher warns on stderr when the seat's config.yaml doesn't pass OPENRIG_SESSION_NAME, and writes nothing to Hermes's config.
- The guide's Hermes section explains the gateway environment and gives the check: ask the seat's Hermes to run rig whoami --json.
No OpenRig code changes. The example test checks the config and the launcher warning against a stand-in hermes; Hermes itself doesn't run in it. Cost: docs and example +33/-1, tests +20/-2.
Concurrent Claude status-line collectors for the same seat no longer collide on a shared ${outputPath}.tmp. Each invocation stages its JSON through a unique same-directory temp file (.tmp-<uuid>), so one rename can't consume the other's temp file and skip its provider-usage cache write. If the write or rename fails, this invocation's temp file is removed and the original error is rethrown to the existing handler, so a persistently failing refresh leaves no files behind.
Reported in #1861, which the PR closes.
A documented way to run OpenClaw's CLI as an OpenRig community harness, with OpenRig naming each seat's conversation through names_conversation.
- The example (docs/reference/community-harness-examples/openclaw/): a preset that declares names_conversation (not yet resume), and a launcher that runs `openclaw chat --session <name>` (the TUI with --local) with an OpenClaw state folder per seat. The agent runs in the seat's pane with no Gateway; Gateway-attached use isn't covered.
- The resume check is the launcher's: before reopening a key, it checks that the seat's `openclaw sessions --json` lists it, and exits without starting anything if not, so a gone conversation isn't recreated under its old key.
- The preset's limits: OpenClaw's automatic session.reset must stay off (with it on, a key no longer names one conversation), and /new in the pane moves the chat to a fresh key while the seat keeps the name OpenRig gave it.
- A short section in the preset reference.
A hermetic test runs the launcher against a stand-in openclaw (no OpenClaw, no model). No real OpenClaw has run. Cost: docs +67, tests +49; no product code.
rig down gives back a guidance file's exact bytes. After the seat-skills line (or any OpenRig managed block) has been added and down removes it, the person's CLAUDE.md or AGENTS.md is byte for byte what it was; a file the writer created is deleted when nothing else is left. Before, down collapsed blank lines, trimmed and added a final newline, so only a file ending in exactly one newline came back the same.
removeManagedBlocksFromFile now takes OpenRig's blocks out exactly as mergeManagedBlock put them in: each block, the "\n\n" written before the first block (or nothing when that block started a new file), and the newlines between and after blocks, including the one a re-merging start adds. Text a person adds is kept, including the bytes around a block kept because someone edited it. Blank lines a person adds right next to a block go with it, since they can't be told apart from the writer's. A file that existed before keeps its bytes, even if empty. Any other shape (a legacy RIGGED block, text against a block without the separator) falls back to the old cleaning. Blocks are found with stripManagedBlocks' own pattern, so this never removes more than cleanup did.
Cost: source about +34/-4 in managed-blocks.ts; tests in rig-teardown.test.ts; no new refusal or error code.
A harness that takes a conversation name from its caller can run as an OpenRig community harness, with OpenRig naming each seat's conversation and resuming it by that name, and a gone conversation is never replaced.
- names_conversation: true (a new optional preset field): on a new launch OpenRig starts the command with OPENRIG_CONVERSATION=openrig-<seat>-<8 hex>; on a resume it passes the saved name as OPENRIG_RESUME_REF. The name is recorded with provenance passed, and the seat shows it as named by OpenRig, not observed.
- Preset validation: names_conversation and launch_result can't both be true, and resume needs one of them (a load-time check on the preset, not a launch refusal).
- Readiness is process-only for a named seat, and the saved name stays unverified until something observes it. A resume never asks the harness to create a conversation; a gone one ends with the harness's own error and OpenRig starts nothing new.
- A Hermes example (docs/reference/community-harness-examples/hermes/): a preset declaring names_conversation and a small launcher with a Hermes home per seat. The model, provider and agent.max_turns go in each seat's config.yaml, not in flags, since Hermes refuses differing creation flags on a named session.
Native, on ovh03 at 5f97e871: the launch reached Hermes's prompt with its settings from the seat's config.yaml; the recall checks were not run. Cost: source +63/-23 in 5 files, tests +115/-5, docs about +90; one new field and one provenance value; no migration and no Hermes-specific daemon code.
Host-binding writers wait out a slow lock holder instead of writing over it. recordHostObservation writes under an mkdir lock; a waiter used to busy-wait 40 x 5 ms (about 200 ms) and then write unguarded, so on a loaded machine its write could land between the holder's read and its rename, and one learned binding or a recorded host-id conflict was lost. That's how CI's host-bindings contention test failed once (w1: expected undefined to be 'host-1111').
- Waiters sleep (Atomics.wait) instead of spinning, and wait 3 s, past the existing 2 s stale takeover, so a slow holder is waited out or taken over as stale, not written over.
- A non-EEXIST mkdir failure (an unwritable folder) fails open at once, as before, without the longer wait.
- Unchanged: the lock, the stale takeover, the file format and the uncontended path. The only writer is rig host doctor; the daemon only reads the file.
A new deterministic test has a child hold the lock 600 ms and then write its stale copy; it fails on the old writer and passes on this one. Cost: source +14/-9, test +28/-1; no new refusal or error code.
rig mcp serve --http serves the same MCP tools over Streamable HTTP, so an assistant that can't start a local process (a cloud assistant reaching a machine through a private tunnel, or a client on another host) can hand work to the team.
Who the caller is:
- No token file (default): the server's OPENRIG_SESSION_NAME, declared, not authenticated; loopback only, with the SDK's Host check against DNS rebinding.
- A token file: the seat of the grant whose bearer token the request carries, for that grant's rigs, roots and tools. Every request needs Authorization: Bearer, including on loopback, since a tunnel lands there. A missing, invalid or revoked token is 401 unauthenticated and never falls back to the env's seat. Once auth has been seen, a missing or unreadable token file refuses every request (500, with a generic message) until the listener restarts with no file.
- Listening beyond loopback (--bind) needs a configured grant. rig_whoami reports caller: authenticated with its principal, or declared.
Grants: rig mcp token add <name> --seat <session> [--rig] [--root] [--workspace] [--owner] prints a token once and stores only its sha256 in $OPENRIG_HOME/mcp-http-tokens.json (mode 600, atomic, add and revoke serialized under a lock file); entries are validated, and a grant always carries its own non-blank seat. token list shows no tokens or hashes; token revoke takes effect on the next request, and records stay.
What a non-owner grant reaches: each tool in MCP_OPERATIONS carries an access class (work, workspace, owner). Work tools are scoped to the grant's rigs: reads see rows whose source or destination is in scope, a queue update needs the row's owner in scope, and capture dispatches the rig the scope check authorized. Owner tools are offered only to owner grants. Each HTTP request gets its own daemon client and server, so concurrent callers never share an identity; stdio and every CLI command are unchanged.
Cost: source about +500 across mcp-http.ts, mcp-server.ts, commands/mcp.ts and client.ts; tests in mcp-http-daemon.test.ts on the real daemon routes and listener; refusals as Root's disposition selected. Native external-client proof is owed.
Four protections for the seat-skills line, the one line OpenRig can add to a seat's CLAUDE.md or AGENTS.md when a person opts in with rig setup --router-line yes:
- An edit to the line is kept. rig down leaves an edited block in place and names it ("Kept 1 edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:"), and a start leaves it as it is, reports the file as edited and warns seat_skills_line_edited. An unedited line is still removed by rig down, back to the file's original bytes. "Edited" is the exact bytes, ignoring only the writer's own framing (edge newlines, CRLF), so an indented line counts as an edit; down and start share that rule (managedBlockBodyMatches).
- A linked guidance file isn't written through: the start leaves the link and its target alone, reports linked with a seat_skills_line_linked warning, and starts the seat without the line.
- A resume honours the answer: rig up --existing puts the line back only with a yes, never through a link, and doesn't report the skipped line as a restore gap.
- Setup says what is actually written: "one line, plus OpenRig's markers so it can be removed cleanly", and the preview prints the exact text from the writer itself, with where it goes.
Other managed blocks behave as before; they still follow links, which many people use on purpose. Cost: source and docs about +179/-32, tests +198/-3; new outcomes linked and edited with their warnings, none refusing a launch.
A reference Cursor launcher and preset for OpenRig's community harnesses (docs/reference/community-harness-examples/cursor/): the launcher creates a chat with cursor-agent create-chat, reports its id through the launch record (OPENRIG_LAUNCH_RESULT), and reopens it on resume (OPENRIG_RESUME_REF). A recorded chat that no longer exists is reported as missing, so OpenRig starts nothing new instead of showing a blank resumed conversation. The presets reference links the example, and one test drives the launcher against a stand-in cursor-agent.
Cost: docs and example +153, one test; no daemon change.
Preflight profile resolution runs in one lazy worker per preflight, so missing-name skill scans and synchronous catalog inspection no longer hold the daemon's request thread (Refs #1653). The existing resolver is reused for each member with fresh filesystem reads, no scan cache and no new setting, and the worker ends when the preflight completes or throws.
If worker startup fails because the daemon's working directory was deleted (uv_cwd), resolution falls back to the existing synchronous implementation, which is the behaviour before this change, in that case only; other errors still propagate. Instantiation's launch-member loop and first-start retry still resolve synchronously and are left for a follow-up. There is no request cancellation or I/O timeout here.
A seat's own role, written as the documented spec shape (an agent's guidance/role.md as a startup file with no delivery_hint), is now delivered to that seat. Before, pod-mates of the same agent overwrote each other's role in the shared CLAUDE.md or AGENTS.md, and the last launch won. #1840 closed the same collision for saved projections; this is the startup-file route to it.
- One predicate, isPerSeatRoleStartupFile(path, orientation) in managed-blocks.ts, beside isPerSeatRoleGuidance and sharing its path rule: a startup file is a seat's own role when it's marked orientation: role or sits at the per-seat guidance/role.md path.
- The shared auto resolver checks it first, so an auto role file is send_text wherever the hint is resolved: Claude, Codex, Pi and stub delivery, the instantiator, the startup orchestrator, the seat lifecycle and the bundle view. Restore uses it too.
- An explicit delivery_hint is kept as written; shared guidance at other paths still merges.
- Seats already launched keep the hint resolved at their launch in their saved startup context, so a restore, relaunch or handover of an existing seat keeps merging until the rig is brought down and up again from its spec (rig down, then rig up <spec file>). The agent-spec reference's auto row says so.
Cost: one predicate and an optional orientation argument through the resolver's callers; a case per runtime; no new refusal or error code.
A contributor's community harness can reopen a conversation its launcher reported, and a conversation that's gone, unconfirmed or different is never quietly replaced.
- A preset sets resume: true alongside launch_result: true. On a resume, OpenRig tells the launcher the conversation through OPENRIG_RESUME_REF, and the launcher reports what it did in the same attempt's record.
- Reopened: the record names that conversation, and it's recorded as before.
- Gone: the record says error "missing". The launcher starts nothing, and OpenRig reports missingConversation with no fresh fallback; a restore rolls back on it.
- No usable record, or a different conversation: the seat needs attention (attention_required), keeps the conversation it asked for shown as not confirmed, and nothing is restarted.
- Only this harness's own token (community:<name>) is passed to its launcher. A former runtime's id makes a restore stop and ask, in the preview and in apply, with the complete one-seat fresh-launch command; a seat with no saved conversation stops with the usual reason.
- Presets without resume behave as before: a restore stops and asks. resume without launch_result is refused. A resume whose record folder can't be made starts nothing.
- docs/reference/community-harness-presets.md gains the resume section.
Cost: source about +107/-24 in 9 files, tests +145/-4, docs +25/-7; no new refusal code, flag or migration. A real harness's handling of a missing id is a native question not yet run.
An agent managing a person's subscriptions across two machines can move a Codex home on the other machine from where it runs: check the home, switch its saved profile, restart its seats and verify them.
- --host <id> on rig auth status, rig auth list, rig auth switch and rig seat restart (with --plan and --verify) runs the same command on that registered machine over its SSH entry, through the existing cross-host executor, as this seat. The remote command's own stdout, stderr and exit status come back, so --json stays that command's JSON; a [via host=...] line goes to stderr. An HTTP-only entry is refused with the fix (name the machine's SSH entry, or run it there).
- --codex-home <dir> on rig auth status|list|switch names the home to act on, here or there; a remote switch requires it.
- A lost SSH connection during a remote switch or restart reads as remote-outcome-unknown with the check to run, never as failed, so a restart isn't repeated blindly. The origin carries this machine's persisted host id, as rig send --host does, and known remote results pass through verbatim.
- A remote restart's audit names the calling seat as its operator unless --operator does.
- The openrig-user skill's "Move the home" step uses --codex-home, --host and the SSH entry.
What crosses: only each command's argv (runtime, profile name, home path, seat, reason, operator) and the origin seat out, and that command's non-secret output back. No credential moves, and there is no generic remote runner.
Cost: source about +200 in three CLI files plus the skill step and its digest; tests in move-commands-host.test.ts; no new daemon route, dependency or migration.
Carries 0.7.0's managed-refresh log line onto main: the plugin vendor's message now reads that it preserved the previous contents of a managed file at its backup before the managed refresh, exactly as 0.7.0 (99aaada6) prints it, with its test. Found by the desk's 0.7.0 canonical-line read: this was the one code change in 0.7.0's release commits that hadn't reached main. Cost: +1/-1 source, +1/-1 test.
The 0.7.0 release boundary's docs on main:
- the packaged capability map (onboarding-width/public-what-you-can-do.md) now carries 0.7.0's capability delta, so an agent reading it after install learns what it can do now, under the header marker capability-delta-v0.7.0;
- the CHANGELOG marks 0.7.0 published;
- the README's skill install (npx skills add ... /tree/v0.7.0/skills/rigs) and install script links (raw .../v0.7.0/scripts/install.sh) point at v0.7.0 instead of v0.6.12.
Fact-only: each line matches the published v0.7.0. Cost: +74/-10 docs, no code.
Advances development main to 0.7.0 now that 0.7.0 is published, and carries its release text, the same way #1558 did for 0.6.12.
- Version fields only, 0.6.12 to 0.7.0, in the root, cli, daemon and ui package.json and package-lock.json; no dependency or script change.
- docs/reference/whats-new.md is byte-equal to v0.7.0's (tag v0.7.0, c99450f3).
- The install pins in getting-started.md and quickstart.md name v0.7.0.
This keeps main truthful about the published release while it continues toward 0.7.1; the release branch itself is unchanged.
A contributor's community harness can tell OpenRig which conversation each launch created, and the seat shows it honestly: reported by its launcher, not observed. This is the first step for harnesses that make their own conversation id before they start; Cursor's create-chat is the first target.
- The opt-in: a format-1 preset may set launch_result: true. OpenRig then starts its command with OPENRIG_LAUNCH_RESULT (one new file per launch attempt, under $OPENRIG_HOME/state/launch-results/) and OPENRIG_LAUNCH_ATTEMPT, reads that attempt's record once (up to 20 s), and records nativeRef on the session with provenance launcher.
- A bad record never stops the launch: missing, malformed, from another attempt or harness, reached through a link, or over 64 KiB, the seat starts with its identity unavailable and the reason named.
- A report isn't a verification: the restore preview and crash-cart don't read the seat as resumable. A launch-only harness still stops and asks at restore, the preview forecasts that decision, and its reason names the complete one-seat fresh-launch command.
- Presets that don't opt in work exactly as before. docs/reference/community-harness-presets.md documents the preset format and the record.
Not in this change: resume of a reported conversation, a reference launcher, and native evidence with Cursor.
Cost: source about +171/-18 across 10 files, tests about +110/-4, docs +70/-1; no migration, no new dependency, no new refusal, and no code runs from a preset.
Adopting an existing session into a rig no longer types its identity hint into a seat waiting at a native approval prompt. At the default interruption level (off), the hint used to be pasted and Enter pressed even at a prompt such as Codex's "Press enter to confirm", and that Enter could accept whichever option was highlighted.
The claim hint now goes through SessionTransport's guarded send as an OpenRig notice (claim@system), the path the startup retry already uses:
- the seat's interruption level decides whether it is typed or held; a hint held by the level stays readable with rig seat held-messages, as one entry per occupant;
- at every level, a seat showing positive evidence of a native prompt gets nothing typed;
- a seat whose level holds OpenRig notices, including an unconfigured terminal member, holds the hint on a real claim too, so it no longer reaches a terminal's shell as a command line;
- the claim itself still succeeds, and a refusal is logged.
Two behaviours now match other notices: a Codex seat whose foreground is a shell OpenRig can't verify refuses the hint (target_runtime_unverified), and a pane in copy mode is taken out of it before the paste. No guard or transport code changed.
Cost: +26/-12 source in claim-service.ts; +115 test lines (a new claim-hint-prompt-guard test, four mock additions); no new refusal or error code.
A Fleet agent that moves a Codex home to another subscription can file one report that it, after its own restart, and the Fleet app can read back.
- rig provider moves report / POST /api/provider/moves files the report as a queue item to whoever asked, tagged subscription-move: host, home, from, to, reason, policy version, readings (observed, expected with its reset basis, or unavailable), each seat's result with its evidence, and peers.
- rig provider moves / GET /api/provider/moves reads them back, newest first, with followedBy, unreadable rows and an honest bounded window.
- Checks before anything is stored: a moved seat needs matched account evidence and an observed tool turn; unconfirmed or unknown needs what's missing, and that honest form always passes; recognized credential formats are refused anywhere in the input, keys included, and never echoed. A refusal records nothing and says the move is unchanged. Nothing here switches or restarts.
- openrig-user gains the account-policy lifecycle, observed versus expected room, unknown sign-in, balancing, and the report command.
This is slice 18's first increment, part A. The Fleet page and the two-host native journey are still to come.
A person can choose how far OpenRig and peer agents may act on a seat. `rig seat set-terminal-policy` saves one of three presets (no peer interventions, help within my instructions, full control), named helpers, and explicit self, OpenRig and peer exceptions for managed compaction and saved model and effort changes. `rig seat status` shows the planned rules, the attribution and what is actually enforced.
Existing seats keep their behaviour (migration 128 backfills full control), and a rig default is copied only to seats added afterwards. Checks cover managed Claude compaction and the saved model and effort setters, including just before compaction input; an already-owed restore or audit continues, and an uncertain paste stays uncertain. A selected denial returns terminal_policy_refused. An unreadable policy keeps OpenRig's own automatic compaction running while the seat's account reads unknown. Other interventions are guidance only, and attribution is cooperative: unattributed callers, dangerous sends, interruption levels and native permissions are unchanged.
This is the first increment of the terminal policy plan, with PM's three conservative defaults. Cost: two policy tables, a node-insert trigger and a setter.
Three read-and-decide steps for an assistant using OpenRig through MCP (slice 07, I2b):
- Read a finished item's artifact: its evidenceRef is read through the daemon's existing files routes when it falls under one of the daemon's shared roots, matched as written rather than resolved on the MCP machine, with a bounded output. Otherwise it reads unavailable, never a local path reported as delivered.
- See a pending human question and record a delegated decision through the existing route, inside the person's active decision room, as the agent that asked, beside their answers and never as one; it's refused when there's no room.
- Follow a revision link back through the existing chain of record.
No new daemon surface: the tools sit on existing routes. Output is bounded; the daemon's reader still loads the whole file.
After opting in to service bundles, `rig bundle install <bundle> --plan --target <dir>` previews the services the install will run, instead of saying archives can't run services and pointing to a source folder. The plan branch of the bundle install route now forwards targetRoot, as the apply branch already did.
One route-level test: a target-bound plan of a service bundle returns `planned` with its services_plan stage for that target, creates nothing and launches nothing. Cost: source +1/-1, test +20.
## What a person sees
A rig is relaunched under the same name, and the relaunch archives the earlier generation. After that, a person's top-level message in the rig's mapped Slack channel doesn't reach the rig's lead. It goes to the connector's inbound destination instead, as if the rig had no representative.
## Why
`resolveRigRepresentative` (`queue-owner.ts`) counted every `rigs` row with the name, archived ones included. With the archived generation and the new rig, it saw two rows and returned null. That's its rule for an ambiguous name. Two callers use it:
- **Slack:** the inbound channel destination (`slack-subsystem.ts`) falls back to `inboundDestination`.
- **Edge ops:** `rig edge add` and `rig edge remove` (`rig-edge-ops.ts`) stop reporting representative changes in `routing`.
## The change
The name now matches active rigs only, the way the CLI's rig handles do (`rig-handle.ts`). An archived generation no longer makes a relaunched rig's name ambiguous.
- **Two active rigs with one name** still resolve to null, as before.
- **A name that only archived rigs have** now resolves to null too, and the message goes to the inbound destination. This is deliberate: a rig name resolves among unarchived rigs, as the CLI's handles do. Archiving isn't a lifecycle state, and a running rig can be force-archived, so an archived rig stays reachable by its id, by a channel's explicit `inbound` seat and by its threads. Only a top-level message routed by the rig's name falls back.
- **`resolveRigRepresentativeById`** is unchanged.
## Tests
- `slack-shared-foundation.test.ts`: with an archived earlier generation of `workshop`, the resolver returns the new rig's lead. The Slack subsystem's own composition (`resolveInboundChannelDestination` with the resolver) routes the channel to that lead. With only archived rigs, it's null.
- `rig-edge-route.test.ts`: with an archived rig of the same name, an add that gives the rig a second delegation root still reports `representative: { from: lead, to: null }`.
Both cases fail before the change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`rig down` warns and asks `[y/N]` when the caller's resolved seat belongs to the target rig. Without a TTY it exits 1 without stopping and gives the exact `--yes` command; JSON carries the same consequence and action. `--force` still controls immediate killing independently of confirmation. OpenRig does not stop externally attached sessions.
Membership uses the existing `whoami` resolver and compares rig IDs. With `--host`, a name-only caller remains unresolved because another instance may have the same session name; a node-ID match still asks. Unresolved callers retain existing teardown behavior. The cost is one additional identity read (2s timeout) when a usable identity exists, and a confirmation step for resolved own-rig shutdowns. There is no daemon teardown change.
Validation:
- Round 2: remote-name collision reproduced before the fix; 124 shutdown/identity/remote-host tests pass afterwards, plus CLI typecheck and `git diff --check`.
- Prior head: complete CLI suite passed (5,423 tests, 3 skipped), workspace build and lint passed. Actual readline PTY check: yes sent one fixture teardown; no and EOF sent none and exited 1.
No live rig stopped. Full final-head required checks and installed-scenario coverage remain with CI; the actual interactive prompt was checked through the PTY fixture.
Before anything changes, a person can see each seat's planned permissions and choose whether OpenRig adds its one router line to shared guidance files.
- rig seat status shows the native permission argument a seat's next fresh managed launch will pass, where it comes from (a seat choice, a member or rig policy, the kernel default, the team default, or the floor), what OpenRig adds (team or kernel lists, Codex writable roots, non-interruptive flags) and what isn't known (native settings OpenRig doesn't manage, whether the harness applies it, Codex's network default). A seat with no policy shows the floor it actually launches with; a Codex rebuild that carries its predecessor's permissions is named as conditional. It's labelled planned, not observed.
- rig setup previews the router line, the files it goes in and what a no gives up, and saves the answer with --router-line yes|no; a saved no stops only that line, and the environment setting still takes precedence.
This is a partial step: it covers existing seats, not a pre-launch view of a new team, and it doesn't move OpenRig's other writes, which setup now lists accurately.
After a successful single `rig send`, the sender's outbox audit row now reads delivered, with its time, instead of staying pending forever. The route already recorded the entry and dropped it; it now marks it delivered with the existing markDelivered, as broadcast does for its successful results. "Delivered" here means the input was dispatched, the same as broadcast.
Unchanged: retained, submit-only, anonymous and already-ledgered sends; a failed or refused send still writes no row; old pending rows aren't backfilled. The queue's recovery scans select only wake-intent rows, so these audit rows were never re-delivery candidates.
Refs #1813 (part of the outbox report; the reporter's event-loop stall and the stuck-sweep recovery are separate). Regression test by review-r2@ovh07, rebuilt beside the A3 control: it fails on main with pending and passes here. Cost: source +3/-1, test +16.
The secrets-manager example's Compose file publishes Vault's port on the host's loopback only (127.0.0.1:8200:8200 instead of 8200:8200). Someone who opts in to service bundles and installs it on a box with a public address no longer exposes a Vault dev server, with its fixed dev root token, to the network. The built-in rig up secrets-manager team uses the same file.
Nothing that reads Vault changes: the specialist's context and skill, wait_for and the surfaces already use 127.0.0.1:8200 on the host, and the container still listens on 0.0.0.0 internally for its own healthcheck. Cost: +1/-1.
A seat saved from an older spec no longer writes its own role into the shared CLAUDE.md or AGENTS.md, where the last seat to write it overwrote every other seat's role. Restore no longer brings that block back from old snapshots.
One helper, isPerSeatRoleGuidance, replaces the five rig-role checks in the Claude, Codex, Pi and stub adapters and in restore: it matches rig-role, or a role block whose source is a seat's own guidance/role.md. Shared guidance from any other path still merges. Each skip adds a warning to the projection result, because a seat whose only role delivery was that merge now gets its role by send_text only. A shared guidance resource with the id role at a guidance/role.md path is now treated as a seat's own role; give it another id or file name to keep it shared.
Cost: source about +40/-23 across the adapters, the helper and restore; tests for the adapters, restore and the historical collision, which now asserts that neither seat's role merges.
An assistant connected through MCP can now delegate usefully and come back for the result: rig_team names a rig's lead (its one delegates_to root, resolved by id) and each seat's role, runtime, delegation and activity; rig_queue_show follows a handed-off item to its successor; rig_queue_list takes since for finished work; rig_slices, rig_mission, rig_context_pack and rig_context_get read the work context without returning host paths.
The daemon's queue list gains handedOffFrom and updatedSince on both list paths (one shared helper, one new 400 code updated_since_invalid for the new parameter). rig ps's seat-activity formatter moves to a shared module unchanged. An older daemon that ignores the filters is reported as filter_unsupported rather than returning a list that only looks filtered.
Cost: source +312/-36, tests +205/-6; no dependency or migration.
Extends the existing installed daemon-restart scenario with the MCP server actually running over stdio from the installed package, as an assistant would use it: rig_whoami with and without a configured seat, rig_queue_create and rig_queue_show with the seat's attribution, structured success and error results including the daemon's own 404 code, and the server's instructions. CI now notices when the MCP surface an assistant depends on breaks in the installed package.
Test-only: five files, net +112, in packages/test-system/ci and the scenario admission tests. No product source, dependency, model, container or scenario-grammar change.
On a fresh machine, rig up --plan auto-started the daemon to compute the plan, and starting the daemon booted the kernel's agents, which could spend model turns before the plan reported anything. A preview shouldn't start agents.
Now, when the daemon is actually stopped or stale, --plan stops before starting anything and says how to proceed: rig daemon start (with the kernel), rig daemon start --no-kernel (no agents), or rig up without --plan. With --json it answers { ok: false, code: "daemon_not_running", plan: true, changed: false }. A blocked or unconfirmed probe keeps its existing guidance and cause instead of claiming nothing is running. Plain rig up still auto-starts, and a running daemon still answers the plan.
New behaviour: --plan with no daemon exits 1 (daemon_not_running) instead of starting one.
Agents sometimes printed the activity-hook token while looking for a seat's identity. This tells them where to look instead.
- rig attach --json's help says its output includes the activity env the attached app needs, that the token is a credential, and to use rig whoami for identity.
- The openrig-user skill's self-attach notes and both copies of the rigs skill say to use rig whoami or rig seat status for identity or state, not the attach JSON or a file holding a seat's environment, and not to print OPENRIG_ACTIVITY_* values.
Text only (+7/-3), plus the regenerated skill digest. rig attach's human output and --print-env never carried the token and are unchanged. The plugin skill text reaches existing installs when the plugin version next advances.
After rig down, its own next step, rig up <rig> --existing, read a seat joined with rig attach as an external CLI (for example Claude Desktop) as stale: it was marked detached, then stopped at awaiting-decision or replaced by a tmux occupant. A launch naming that seat replaced it too.
Now a full restore and a targeted launch leave such a seat attached and report it as left_attached ("OpenRig can't see whether that app is still running"); a tmux seat beside it restores as before. The restore forecast (rig up --existing --plan, rig status, the recovery view) says leave-attached with the same reason, and rig start prints the reason. rig down's line now reads "Not stopped: … OpenRig doesn't stop or check" an externally attached session.
left_attached is a new per-seat result value and, for a single-seat rig launch on an attached seat, a new HTTP 409 code; to replace the attachment deliberately, use rig unclaim <name>, then launch.
The first increment of a maintained MCP surface for outside assistants (Claude Desktop, ChatGPT and others).
- rig_whoami: the seat this server acts as, from the configured OPENRIG_SESSION_NAME (not a verified caller). With no seat set it says so and calls nothing.
- rig_restore_status: follows a restore's attemptId to its receipt. An attempt with no completion yet, still running or stopped before it finished, answers attempt_incomplete; it isn't a receipt. rig_restore now says it only starts a restore.
- Structured results (structuredContent) for whoami, restore status and the queue tools, beside the unchanged text.
- One error shape, { ok: false, code, message, status? }, on errors the tools return. The code is the daemon's own (from code, from error beside message, or a code-only error), else http_<status>. Existing error text is byte-identical. The SDK's own argument validation stays text-only.
- Four lines of server instructions, and a short operation table beside the registrations with a test that every tool's routes are mounted and its hints match its effect.
Tool names, arguments and stdio are unchanged. No daemon change. Cost: +172/-49 source in one file, +214/-8 tests.
Adds docs/reference/a2a-agents.md: how an OpenRig seat hands a queue task to an outside A2A agent and gets the result back on the row, and how an outside A2A client reaches a seat and gets its result as an artifact. It composes the official A2A Python SDK 1.1.0 with existing queue commands; there's no product-code change.
It covers task identity, result ownership, input requests, cancellation and uncertain writes; keeps quiet streaming waits open, with how to stop one locally; names the tested SDK 1.1.0 stack and the observed incompatibility with Pydantic AI 2.60.0 / FastA2A 2.1.1. Pipe trials on SDK 1.1.0 exercised both directions. Whether a cold agent can follow the page unaided is still untested.
Text only: +343/-0.
Brings OpenRig's MCP server, the way Claude Desktop and other assistants reach a team, up to date with the core.
- rig_send and rig_capture send the terminal bearer token when the daemon requires one, so they no longer get 401. The token goes only on those two transport calls.
- rig_up takes cwd (absolute, since the server has no caller directory), env (one rig spec or bundle; the same rule as rig up --env), rigName and fresh, and its description says what the route accepts.
- rig_chatroom_send resolves an id or a name like the other rig tools and drops the unused sender; rig_chatroom_watch says it returns recent history and takes topic, sender and limit. Both report an error instead of an empty room when the rig list can't be read.
- rig_queue_update takes resolution and reopen.
- Every tool carries MCP annotations: 12 read-only, 4 additive, 8 destructive, so a client can grant standing consent to reads.
Tool names are unchanged. Cost: +100/-51 source in one file, +124/-3 tests. New input checks: an invalid env entry and a relative cwd on rig_up's new arguments.
A seat finishing a row could only put its artifact link in the resolution text: evidence_ref was refused on every update except a park on a human seat.
Now `rig queue update --state done --evidence-ref <path>` stores the pointer on the closed row through the existing persist path, and `rig queue show`, `GET /api/queue/:id` and the MCP server return it. Cancelled and handed-off closes still refuse it (a handoff already puts the pointer on its new row), and every other non-park update refuses it before changing anything, with the same error code. A park on a human seat behaves as before. Slack's queue projection carries the field; this doesn't add completion posts.
Cost: +15/-7 source in 3 files, +27 tests. One refusal removed; no new refusal or error code.
The root CHANGELOG on main stopped at 0.6.12; the 0.6.13 and 0.6.14 entries existed only on their release tags. This adds them exactly as they appear on v0.6.13 and v0.6.14, and adds 0.7.0 from its release notes, marked unreleased until it's published.
Text only: CHANGELOG.md, +495/-0.
Adds two checks for upgrade problems people have hit.
1. The installed daemon-restart scenario now seeds an older plugin and an edited binary. When the backup is denied, the restart must skip the managed refresh and leave every target unchanged, with a specific warning. Once permissions are restored, it must keep the exact recovery bytes and move to the shipped version. A missing or corrupt proof fails the report.
2. The package-content test reads the same tarball's what's-new heading and intro against its package.json, with independent stale-heading and stale-intro controls.
Test-only: 5 files, +83. No product, scenario-grammar, scheduler or dependency change. One existing installed case gains one extra daemon start and file hashing.
docs/reference/operation-correlation.md now states two facts from the code: replay matching compares the exact request text, so a change in whitespace or key order is a different request, and an operation ID that isn't a UUID gets HTTP 400 before the route does anything else. A different request reusing an ID gets 409 only once it passes the route's own validation.
Cost: +1/-1, one paragraph. Text only.
A seat joined with `rig attach` as an external CLI (for example Claude Desktop) has no tmux session. `rig down` read that as a stop and recorded it, and a subset launch marked the seat detached and held, while the app kept working.
Now both loops skip a seat whose binding is an external CLI, the way the historical stop path already did. `rig down` lists those seats in a new optional `leftRunning` result field and prints one line about them; they keep their skills and guidance. `rig down --delete` still deletes the rig. No new refusal or error code.
Not changed here: a later full restore (`rig up --existing`) still treats such a seat as stale; that follows separately. `rig remove` and `rig shrink` use a different path.
Cost: +23/-2 source in 3 files, +35 test lines in 2 files.
After #1791, a Codex seat could still keep generated writable roots because OpenRig read a writable config above Codex's actual project root. Codex ignores that file, stays read-only, and can then reject the resulting --add-dir arguments.
Now the visible project-config scan stops at the root Codex itself discovers. Above-root and malformed configs no longer count. Where the boundary is uncertain (Git markers it can't read, custom or managed markers), existing roots are kept, as before. No new refusal or error code.
Thanks to @bradvin for the follow-up and for merging main to keep #1824's plugin wiring intact.
Cost: +38/-8 production lines (code +25/-4, comments +13/-4), +146/-4 test lines.
A client that loses a create, import or add-member response after the daemon has already created resources had no way to tell which resources came from its own request; retrying with a fresh request could duplicate work.
Now a client can send a UUID in X-OpenRig-Operation-Id (advertised as operation-correlation.v1 at GET /api/operations/capabilities). The daemon records the rig and node IDs that request creates inside the existing transactions. Replaying the exact same request returns those IDs and their current database state without executing again; the same ID with a different request returns 409. Requests without the header behave exactly as before. Migration 127 adds one table. docs/reference/operation-correlation.md describes the contract.
Thanks to @bradvin for the change and for reshaping it to the smallest contract.
Cost: +179/-46 production lines, plus tests. The 409 applies only to clients that opt in.
An OpenRig seat had no supported way to hand work to an ACP agent such as Hermes or OpenClaw and get an honest result back; the trials used one-off glue.
Now `rig acp run <row>` starts the agent, sends the row's task as the prompt, posts progress and the outcome as notes on that same row, and leaves closing it to the row's owner. A permission request becomes a row to the owning seat; the answer, given on that row, goes back to the one pending request exactly once, and a cancelled turn's answer is never sent. `rig acp cancel` records requested, acknowledged and upstream spend separately, with spend unknown unless measured. A second Ctrl-C detaches without waiting. Stop reports settled, incomplete or unknown for the processes the run launched, and never kills anything. An empty end_turn with no usage, an OpenClaw interruption chunk, or a transport closed mid-turn reads unknown, not done. docs/reference/acp-agents.md explains how to use it.
Cost: new commands and docs only, with no daemon change or new dependency (a small hand-written JSON-RPC client). No new refusal.
A fresh Codex kernel or team seat either lost OpenRig's own plugin (refocus and skills) or stopped at Codex's review of its six hooks.
Now OpenRig installs its plugin with Codex's own commands at daemon start and on a fresh launch for a selecting seat, and trusts only the plugin's six shipped hook definitions, by exact hash, on a first install (no plugin cache and no config layer already listing it), after checking the hook files' bytes against this daemon's copy. A person's existing disables, hook choices and untrusted installs are kept, and stay under Codex's own review. The four inline hooks remain the only activity reporters; the plugin adds refocus and skills.
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
Cost: +450/-97 in 12 files. No new refusal.
A kernel seat waiting at a prompt, such as Claude's usage-limit menu, wasn't named to the person, and the operator's guidance didn't say that launch grants, team defaults or another seat's answer aren't the person's answer to a trust, hook-review or permission prompt.
Now the operator tells the person which seat is waiting, when it resets and its choices, and keeps the choices the person made during install. It answers such a prompt only when an operation the person asked for names it, or when they told it outright to decide that kind of prompt (step 10).
Cost: text only, +24/-5 (3 files). No new refusal.
When a project root was reached through a symlink or a path with a dot segment, proof reads, judgments and project scopes refused it as a different project (409 project_changed), even though it was the same folder.
Now both readers compare the real paths, so the same folder matches however it's spelled. A link that points at a different project is still refused with 409 project_changed, and a pinned root that was removed still answers 409 project_unavailable with its reason.
Cost: source +8/-3 (2 files), tests +36 (2 files). No new refusal or dependency.
Upgrading OpenRig could silently discard someone's edits to a managed plugin or global skill file: the daemon's startup refresh overwrote the file with the new version and kept no copy.
Now, before a version-advancing refresh writes over a file that differs, the old bytes are copied exactly to a fresh directory under $OPENRIG_HOME/backups/managed-refresh/, and startup logs where. A retry never overwrites an earlier copy. If a copy fails, that plugin or skill is left exactly as it was, with a warning, and startup carries on. Equal or newer installed versions and unversioned external skills are untouched. The upgrade guide now says startup may already refresh files and that a later plan doesn't protect them.
Recovery keeps the old bytes; the custom behaviour may no longer be active after the refresh.
Cost: source +29/-6, tests +88/-0, upgrade skill and its mirror +22/-10, digest +2/-2 (7 files). Backups accumulate without cleanup. No new refusal.
Seats launched by a daemon that was started with a chosen database or a custom tmux directory could pick up a different OPENRIG_DB or TMUX_TMPDIR from the shell, so their rig commands talked to the wrong state.
Now the daemon passes the database it actually selected (resolved to an absolute path, with :memory: kept as is) and its TMUX_TMPDIR, when set, into every launched seat, across scoped launch, its fallback and shell startup. The getting-started page says so.
Cost: source +4/-1 (2 files), tests +53/-11 (4 files), docs +4/-2. No new refusal or dependency.
When a seat's working directory was reached through a symlink (for example macOS's /tmp or /var), OpenRig's generated core-plugin files under .codex/plugins were reported as untracked files to commit, because one comparison used the uncanonicalized cwd.
Now that comparison resolves the cwd the same way as the rest of the function, so those files are excluded quietly, and a second pass adds nothing.
Cost: source +1/-1, tests +13/-0 (2 files). No new refusal.
A fresh Codex kernel or team seat stopped at Codex's native review of the core plugin's six hooks, because OpenRig had started installing that plugin automatically at boot and on fresh launch (#1551, after 0.6.14).
Now OpenRig no longer runs `codex plugin` at all. The boot installer, the fresh-launch install and their helpers and tests are removed. The four managed inline activity hooks and their trust, existing plugin configuration, user hook choices and normal native review stay as they were, and seat-selected skills still project separately.
Cost: source +6/-257, tests +40/-189, docs +9/-14 (7 files). Fresh Codex homes no longer get the plugin installed or upgraded automatically, or its refocus hooks; plugins already installed are untouched. No new refusal.
Contributors running the daemon suite on a Mac saw 19 failures whenever TMPDIR was the default /tmp or /var symlink, because the tests compared paths without resolving it, and one transcript test threw ENOTEMPTY while cleaning up after its private tmux server.
Now the affected fixture roots are resolved with realpath, and that cleanup awaits a bounded async remove (5 retries, 20 ms). No product code changes, and no assertion is added, removed or edited; CI already used canonical paths, so it runs the same checks.
Cost: tests +11/-11 (5 files). No product change.
A seat whose tmux server was gone kept reading a bare `running` in `rig ps`, because an unavailable tmux isn't proof the session ended, and the evidence the daemon already held wasn't shown.
When a seat's last applicable identity observation found tmux unavailable, `rig ps` now explains that recorded running is unconfirmed and shows the observation time. Compact node JSON carries the existing stored status and concise identity verdict; both fields are selectable with `--fields`. No session is inferred dead or detached, and existing attention and stop states retain precedence.
Cost: CLI source +31/-4 (1 file), tests +44 (1 file). No new refusal, error code, daemon state or probe.
The daemon chose its Herdr session once, at startup, from its own environment, so someone running two rigs on one host couldn't keep their tiles apart: a per-command `HERDR_SOCKET_PATH` was ignored.
Now `rig terminal open <view> --provider herdr --session <name>` places a view in the named Herdr session on the daemon's host. Preview, status, desktop opening, workspace reuse and layout use the same selection, and each request has its own transport, so concurrent opens don't change another request's socket or the daemon's environment. Leaving out `--session` keeps the configured endpoint, and `--session default` selects it explicitly. A requested session that can't be used stops with an error instead of falling back to the default session.
Behaviour that changed, for direct HTTP requests: a rig-addressed open now checks the preview it carries, so an out-of-date preview stops the open and asks for a refresh, as the main terminal-open address already did. A `session` value is no longer ignored: an invalid name, a value that isn't text, or a provider that can't select a session (cmux) gets an error instead of opening in the old default place. The CLI also stops, rather than opening silently in the wrong place, when an older daemon can't confirm the selected session or when an in-Herdr window open would cross into another endpoint. When the caller's session is known, that error suggests the matching `--session`, named from the same socket the check used.
Limits: the selected session must already exist on the daemon's host. The tests use real sockets with synthetic Herdr peers; the Herdr desktop app wasn't exercised.
Thanks to @Aschenbath for the PR, and to @team-sudarshana for the issue and for checking the shape on their own host.
Cost: source +174/-69 (7 files), docs +105/-3 (4 files), tests +401/-6 (3 files). The two HTTP changes above were approved by the founder; two new error codes (`invalid_terminal_session`, `unsupported_terminal_session`); the rig-addressed open reuses the existing `preview_changed`.
An agent that parsed the stdout of a default-flag `rig queue show` or `rig queue whoami` as JSON failed, because a human notice followed the JSON on stdout.
Now `show`'s bounded-preview line and compact empty-field notice, and `whoami`'s work-candidate list, go to stderr, as `rig queue transitions` and `queue create` already do. A terminal shows the same text in the same order, and the data behind each notice is already in the JSON (`bodyTruncated`, `currentWorkChoice`).
Limit: with `output.compact` on (off by default) and no `--full`, write commands still print compact mode's text receipt; `--json` gives the receipt as JSON. Refs #1806.
Thanks to @BillyOutlast for the report and its three surfaces.
Cost: source +9/-10 (1 file), tests +13/-9 (2 files). No new refusal or error code.
Watchdog updates kept a second copy of four policies' spec requirements. Reuse the evaluator-owned checks and the pool scanner's existing no-pools rule so those decisions have one source. Scanner error order and evaluator outcomes stay unchanged; full-spec reminder updates now accept whitespace as evaluation already does, while message-only edits still require text. Update errors retain their codes and use evaluator wording.
Cost: source +68/-63 (5 files), tests +22 (2 files). No new refusal or error code.
Managed Claude compaction wrote each restore map under the seat's launch folder whenever the daemon's own write check passed. A seat whose Claude permissions deny edits there couldn't write its map, so preparation ended `preparation_incomplete`, and the only way out was `--skip-map` or many generated permission rules.
Now an operator can set an absolute, instance-wide `policies.claude_compaction.preparation_root` (or its environment variable). Manual and automatic compaction then use `<root>/<session>/<attempt>/RESTORE-MAP.md`, chosen once per attempt and kept through preparation, publication and restore, even if the setting changes mid-attempt. The operator grants Claude edit access to that root; OpenRig doesn't change Claude's permissions. With the setting unset, paths are exactly as before, including the instance-home fallback.
A configured root that can't be created or written, or that already holds a different `.gitignore`, stops that attempt with `preparation_directory_unavailable`, shown in `rig compact --state`, instead of silently choosing another folder. `--skip-map` still works.
Limits: use a dedicated root. OpenRig writes a `.gitignore` of `*` into the root itself, so a root inside a repository folder without its own `.gitignore` hides new files there. No live Claude compaction or permission session was run; the tests use real temporary files with synthetic delivery.
Thanks to @Aschenbath for the PR, and to @aanestesia for the issue and for measuring what the workaround cost.
Cost: source +59/-21 (4 files), docs +43/-1 (2 files), tests +272/-8 (7 files). One new failure reason, `preparation_directory_unavailable`, reached only with the new setting.
A Claude seat whose resume launch failed reported a fixed message, and a refused `clear-attention` returned only the pane's status and code, so nobody could tell which probe result or which login text the decision rested on.
Now `node.startup_failed` and `seat.attention_clear_refused` carry the probe's evidence: its code, which of the fixed login-refusal strings matched and where, the capture's line and byte counts, a SHA-256 of the text the probe read, and the pane size. No pane text or free-form probe detail is stored. Every probe-backed failed return carries it, and a missing strict reconciler records its own refusal without inventing an attempt or evidence. Optional pane geometry has a 250 ms deadline, so a slow or failing read leaves the size empty and the failure is still recorded. Recovery decisions and error messages are unchanged.
Limits: the deadline bounds only the wait; it doesn't cancel the tmux command. The geometry is read after the capture, so it isn't an atomic snapshot. An unexpected reconciler exception keeps its existing error path and isn't recorded as a refusal.
Thanks to @Aschenbath for the PR, and to @AdamWhitehurst for the issue and for checking the PR against his case.
Cost: source +118/-17 (8 files), docs +33 (1 file), tests +237/-10 (5 files). No new refusal or error code; new fields on two existing events.
#1803 merged with a stated limit: its record of replaced schedules kept the last 8 per job, so after nine or more `rig watchdog update` calls during one in-flight delivery, that delivery's receipt could be missed and the same wait notice sent again.
Now the schedule an in-flight delivery started under is kept until that delivery is accounted for, even past the 8-entry trim, and is released when the attempt is recorded. A real reschedule still clears it, so a re-park keeps the due point it set. Jobs that are never updated are unchanged.
Limit: if the transport rejects a send outright or accounting throws, one kept schedule per job can remain until the next delivery replaces it; no later misaccounting follows from it.
Refs #1803, by @AdamWhitehurst, whose `rig watchdog update` this completes.
Cost: source +25/-4 (2 files), tests +52 (1 file). No new refusal or error code.
After a partial restore, the daemon's reason for a seat left awaiting a decision said "Re-run with --fresh <id>". That re-runs the whole-rig restore, which is refused while sibling seats are running, and it was the only instruction a `--json` reader saw. #1800 fixed the CLI's own printed line; this fixes the reason itself.
Now the eight awaiting-decision reasons name the one-seat command, `rig seat launch '<id>@<rig>' --fresh --reason "<why>"`, built by one shared helper that two existing messages also use, with their text unchanged. After a yes at the prompt, a seat that needs attention reads `needs attention --` rather than "not started", and `rig start --json` prints a refusal to stderr.
Limit: for an archived rig, the printed command finds no seat, as before.
Refs #1332.
Cost: source +28/-16 (5 files), tests +38/-5 (5 files). No new refusal or error code.
Retuning a watchdog meant `rig watchdog stop` then `register`, which gave it a new job id and broke anything still holding the old one, such as a `queue block --wake-watchdog` attachment.
Now `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]` changes only the fields given and keeps the job's id, registration, history, queue attachments and every other field. It reuses the existing storage, validation and scheduling. A new interval takes effect from the job's last recorded evaluation, and the response says when it's next due. A queue wait's backoff is retuned with it, so a delivered notice isn't replayed. A delivery already in flight when the update lands still counts. The new verb refuses a stopped or finished job, a daemon-owned or daemon-timed policy, a change of policy or target, and edits that would have no effect; `register`, `stop` and existing jobs are unchanged.
Limit, with a follow-up: the record of replaced schedules keeps the last 8 per job. If nine or more distinct updates are accepted while one delivery is still in flight, that delivery's receipt can be missed, and the same wait notice can be sent once more.
Thanks to @AdamWhitehurst for the issue and the PR.
Cost: source +342/-10 (CLI +43; daemon +299/-10), docs +1, tests +499 (2 files). Seven new error codes, all on the new verb, and one new event, `watchdog.job_updated`.
After `rig start` or `rig up --existing` left a seat awaiting a decision, OpenRig printed `rig up --existing <rig> --fresh <seat>`, which re-runs the whole-rig restore and is refused while sibling seats are running. Answering yes at the prompt did the same thing: `rig start` then advised `rig down`, and `rig up --existing` printed nothing while the seat never started.
Now both the printed line and a yes at the prompt use the one-seat route: `rig seat launch <logical id>@<rig> --fresh --reason "fresh-prime after restore"`. It starts exactly that seat, older dotted ids included (`dev.ops.impl` and `dev.ops-impl` each reach their own seat), and leaves the siblings as the restore left them. After a yes, each seat prints `fresh-primed (<session>)`, or `not started` with the daemon's guidance, or, on a timeout, that the outcome is unknown and to check `rig seat status`. `up` names the rig from the restore result. The rig-spec OMP Restore bullet is updated.
Limits: the daemon's own awaiting-decision reason text still says "Re-run with --fresh"; that's a separate follow-up.
Finishes #1332, by @adampog, who found the refused hint and proposed the one-seat command.
Cost: source +55/-45 (3 files), docs +1/-1 (1 file), tests +160/-15 (3 files). No new refusal or error code.
Co-authored-by: Alan Dampog <[email protected]>
A claimed queue item whose owner is waiting on a person's input read `stalled-after-claim` once the grace period passed, with evidence that never mentioned the input. The input count was already reported elsewhere (`waiting.liveness.needsInput`, the execution view's `needs_input`), but not in the pickup answer itself.
Now the pickup receipt reads `needs-input`, with the reason (for example `owner needs input: permission_prompt`), for a claimed item whose owner has outstanding input. The stall evidence moves into a `stall` field that recovery keeps reading, so the stuck sweep opens and keeps the same stall finding it would on main, and backstops and manual dispositions are unchanged. Queue reads, `rig view show pickup` and the execution view's lanes carry the new state, and an old claim still parks as stalled. Unclaimed, blocked and closed items are unchanged.
Limits: the compact `rig queue show` line shows the input reason but not the second stall explanation (the JSON receipt has both). Pending items that kept their claim stay outside the pickup view and the stall sweep, as before.
Finishes #1313, by @Aschenbath, who proposed telling input waits apart from stalled claims.
Cost: source +32/-9 (4 files), docs +13/-1 (1 file), tests +112 (1 file). No new refusal or error code.
Co-authored-by: Nash Aschenbath <[email protected]>
The stuck sweep's custody finding was titled `Stuck sweep: successor-verification-required on <row>`, which didn't say what had gone wrong or where.
Now the title says where custody wasn't checked: `Custody not checked on host <host> for <row> (N min)` when the successor row is on another host, or `Custody not checked: no successor of <row> in this store (N min)` otherwise. A seat address is never read as a host. The finding's body, routing, tags and dedupe are unchanged, and findings already open keep their old title.
`rig workflow revise --help` now has an Examples block with a mixed change: one reason refuses the whole proposal, so put the protected step back as it was bound, keep the compatible addition, inspect again, then run the printed apply line.
Cost: source +33/-2 (2 files), tests +4/-2 (3 files). No new refusal or error code.
With no daemon running, `--demo` opened the crash-cart screen instead of the demo team, because demo mode has no daemon client and the launch fell through to the crash-cart probe.
Now the launch skips that probe in demo mode, so the demo fixture draws. The normal launch is unchanged. The timezone setting is still read from local configuration under `--demo`; with no `rig` installed, the demo shows a "ui.timezone unavailable" warning and still draws.
Cost: source +2/-1 (1 file), tests +45 (1 file). No new refusal or error code.
A client that shows a person a seat and asks "stop it?" had no way to make sure the stop hit the seat that was shown. A relaunch, handover or rebind in between could stop a different session.
Now `GET /api/seat/stop-precondition/:seatRef` returns the seat's node and latest session under a `seat-stop.v1` marker, and the existing `POST /api/seat/stop/:seatRef` accepts that tuple as an optional `expected`. With it, the stop runs inside the existing lifecycle lease and checks the selection again before the detach, before the kill and before saving the stop. A change returns 409 `selection_changed`, and nothing is killed. A detach that had already happened is reported as `partialEffect`. A kill or save whose result is unknown returns `stop_uncertain`, so the client inspects before retrying. A checked stop needs the delivery guard (`guard_unavailable` otherwise), and a malformed explicit `expected` is a 400.
A stop without `expected`, including `rig seat stop` and the TUI, behaves exactly as today. Setters are unchanged.
Cost: source +141/-16 (3 files), docs +11 (1 file), tests +210 (1 file). New refusal codes appear only when a client opts in.
Thanks to @bradvin for reshaping this to the smallest version we agreed.
A Codex seat launched under a named profile with `sandbox_mode = "read-only"` was given OpenRig's generated writable `--add-dir` roots (Git metadata and the pod's state folder), and current Codex refuses writable roots under a read-only sandbox at startup, so the seat couldn't launch.
Now those generated roots are left out when the profile really controls a read-only launch, on fresh, resume and fork. They're kept, as today, whenever something above the profile could make the launch writable: a project `.codex/config.toml` from the working directory upward with a writable `sandbox_mode` or named permissions, or project trust that the files on disk can't settle. Explicit posture overrides, rebuild permission envelopes and a running process's own roots are unchanged, and no profile or project file is rewritten.
Cost: source +43/-4 (1 file), tests +225/-4 (1 file). No new refusal or error code.
Thanks to @bradvin for working it through three rounds.
After a Herdr restart or upgrade, OpenRig's tiles came back as empty shells while the seats kept running.
Now, once a view is on screen, the Herdr adapter sends one session-only report per tile: `pane.report_agent_session` with the runtime's label (`claude`, `codex` or `pi`) and the tile's reattach command, so Herdr's own restore reattaches the tile. No state is reported, so Herdr's screen detection stays in charge. Herdr accepts the report only once it has detected the tile's agent, so a `resume_not_accepted` refusal is retried, up to seven rounds 500 ms apart (at most 3 s of waiting plus each call's own time). After that, or on any other refusal, the tile gets a note and keeps today's reopen-the-view route. Each tile's command carries its own `OPENRIG_TILE`, so two views of one seat both come back. Tiles over ssh, runtimes Herdr doesn't recognise, names with an apostrophe, and a reply whose tree can't be matched are noted rather than reported. Opening a view never fails because of a report.
Cost: source +113/-1 (3 files), tests +189 (1 file). No new refusal or error code.
Refs #1761 (thanks to @alexveli1 for the in-place reconciler idea).
A contributor on npm 9.x who ran `npm ci` on a fresh checkout got an `ENOENT` on the unbuilt TUI binary with no hint why, because the root `engines` named only Node.
Now the root `package.json` declares `npm >=10` beside the Node range, and the lockfile's root entry records the same. npm 9 prints its `EBADENGINE` warning naming the floor; it warns and doesn't refuse. `CONTRIBUTING.md` already says to use npm 10 or newer.
Cost: config +4/-2 (2 files). No dependency, version or script changes; no new refusal.
A slow daemon stop was hard to diagnose after a later shutdown replaced the single shutdown receipt, because the timing lived only there.
Now the CLI's stop error includes the time this process requested SIGTERM, and the daemon's existing shutdown log lines carry the PID, the time the shutdown callback started and the time it completed. A target that was already dead gets no invented request time. The callback time is when the handler ran, not when the kernel delivered the signal. Stop behaviour, ownership checks, budgets and the receipt format are unchanged.
Cost: source +8/-3 (2 files), tests +54/-3 (2 files). No new refusal, setting or error code.
A healthy owned daemon that took 400 ms to 1 s to answer its health check could lose every 250 ms startup probe, so `rig daemon start` treated it as failed and terminated the child.
Now startup readiness uses one 20 s budget, shared by the primary probes, the listener checks and the backoff, and each startup probe may take up to 2 s within what's left. Once the budget runs out no new probe starts, but a result already verified is still used, with the usual liveness and publication rechecks. A late primary read can't publish through an unverified secondary listener; an on-time primary whose secondary check runs past the budget still publishes with its warning. The pre-spawn, status and stop timing is unchanged, as are the identity, liveness, publication and owned-child cleanup checks.
Cost: source +21/-5 (1 file), tests +95 (1 file). No new refusal, setting or error code.
A seat added with `rig add` or `rig grow` into an existing pod got the rig's culture file and rig startup files (#1632), but not the pod's own startup files. And when an inherited culture file or required rig file was missing, the add went ahead and the agent then failed at delivery.
Now an added seat gets the pod's startup files after the rig layers, in the same order as a seat declared in the rig file. An add also stops, before any seat exists, when a file it would inherit must be present and is missing or unreadable: a `required: true` rig or pod startup file, or the rig's declared `culture_file`. The error names the file, its resolved path and the rig or pod that declared it. `rig grow --new-pod` stops the same way when at least one added member reads startup files. Optional files, restore-only files, and members whose runtime reads no startup files (terminal and community-harness members) are added as before.
Rigs created before #1632 have nothing recorded and are unchanged. Rigs created on main since #1632 have the rig layers but no pod files, so they get no pod layer, and an add into one now stops when its culture file or a required rig file is gone. A saved startup list is still replayed on fresh launch and restore without re-resolution.
Cost: source +84/-13 (2 files), tests +283/-3 (1 file). One new add-time error, for a missing required inherited file, accepted by the maintainers.
After a Herdr server restart or update, Herdr restores its layout, but OpenRig's tiles come back as empty shells while the seats keep running, and the openrig-herdr skill didn't say what to do.
Now the skill says it: reopen the view with `rig terminal open <view> --provider herdr`, which places the tiles in a fresh workspace. That's the stopgap while the real fix, reporting each tile's reattach command to Herdr so its own restore reruns it, waits on a live check. Both copies of the skill change together, with the pinned edge digest.
Cost: docs +6 (two copies of +3), generated digest +2/-2. No code change.
After #1770, a level set while a combined queue wake waited for a busy seat's lease didn't apply to that wake. A routine member could type at `urgent-only`, or an urgent one could be held together with a routine one (#1770's stated limit).
Now, inside the guarded send, each member is re-admitted with its own source and urgency against the configuration the lease sees, through an internal send option that's never accepted from a request. Only members matching the decided outcome go together. The others return to their pre-claim delivery state and are delivered on their own, awaited, so the drain finishes only when every member has an outcome. Every delivery pass, whether post-commit, successor or the startup drain, keeps one attempt history that nested re-deliveries inherit, so a member already sent in the pass, confirmed or not, is never sent again in that pass, and each released member gets exactly one first attempt. Ordinary `off` combining, and a returned result with its resume as one send, are unchanged.
Cost: source +51/-12 (2 files), tests +95/-2. One internal send option; no flag, migration, refusal or error code.
An agent in an app with no shell, reaching OpenRig only through `rig mcp serve`, couldn't follow the daemon's event log.
Now the MCP server has a 24th tool, `rig_telemetry_events`, over the existing bounded read (`GET /api/telemetry/v1/events`, which `rig telemetry events` uses). It takes the CLI's parameters (`cursor`, `start` latest or retained, `rigId`, `nodeId`, `limit` 1 to 128) and returns the same envelope: event metadata only, never payloads, with `coverage.gaps` naming history it couldn't read, a daemon restart included. `rigId` takes a rig name or id, resolved as the other MCP rig tools resolve it, and a handle no rig matches is an error rather than an empty page that would read as a quiet rig. The daemon's 400 and 503 answers come back as tool errors with its body. The `openrig-user` skill and the telemetry reference name the tool. No daemon route, SSE, event-store or queue behaviour changes.
Cost: source +34 (1 file), tests +83/-4 (2 files), docs and skill +8/-4, generated digest +1/-1. No new setting or migration; the unknown-rig error is in the new tool only.
After #1777, a seat swap still stopped a workflow keepalive that the retiring occupant had registered with itself as the fallback target, even while the keepalive was waking another seat that owned the workflow's current step. That workflow lost its backstop wake (#1775's last case).
Now the swap never stops a workflow keepalive, the same way it already keeps a blocked row's park timer. A keepalive picks its recipient when it fires: the frontier rows' owner, then the workflow's creator, its observers, and its stored target last. When that recipient is this seat, the successor now owns the step, so the wake is the right one. A kept keepalive still ends with its workflow: its policy goes terminal when the workflow is missing, inactive or past that packet, and the runtime disarms it as the packet advances, ends or is aborted. Every other job is judged as #1777 does.
Cost: source +4/-2, tests +36. No new refusal, setting or migration.
`rig export` dropped a rig's `culture_file` and rig-level `startup` block (#1631), though `rig up` from a spec stores both since #1632. So an exported team brought up elsewhere lost its culture and startup files.
Now the exported spec carries both, read from the rig's stored startup layers. The YAML export route also sends the rig's recorded root in an `X-Rig-Root` response header, URI-encoded so any path is a valid header value. After writing the file, `rig export` checks each referenced culture file and each required startup file against the output folder. For one that doesn't resolve, it warns that a rig launched from the export will fail its startup until the file is copied next to it, or the export is imported with `--rig-root <recorded root>`. The check never blocks the export. JSON results and request headers are unchanged.
Cost: source +72/-4 (4 files), tests +105 (3 files). No new refusal, setting or migration.
#1614 widened the compact ACTIVITY cell so a count fits, but other cells of `rig ps --nodes` still ran together: a 38-character SESSION such as `orch-rebuild-historian@v-openrig-build` ran into LIFECYCLE, `needs-input (screen)` overflowed its cell, and the `--full` ACTIVITY cell (12) cut the count.
Now both tables join their cells with one space, as the rig table already does, so no cell ever runs into the next and a whitespace split gets each column on its own. The separator sits on top of today's widths rather than inside them, so a real 38-character name isn't cut: the default row grows by 5 columns. The `--full` ACTIVITY cell widens from 12 to 17, as in the default table; the `--full` row grows by 20. JSON output is unchanged. In `--full`, a 38-character SESSION is still cut with `…` (34 wide), and `needs-input (screen)` still shows as `needs-input (scr…`, but neither merges into the next column.
Cost: source +7/-3, tests +35/-9. No new refusal or setting.
Rebuilding a Codex seat in place (`rig handover --source rebuild`) relaunched it with fresh-launch grants: network on and extra writable directories, even when the running agent had a narrower sandbox (#1773). So a deliberately restricted Codex seat came back wider after a rebuild.
Now the rebuild carries the predecessor's observed permission policy when the observation matches the current pane, process, conversation and working directory, and its shape is a supported sandbox and approval pair. A pending permission or policy choice, or a differing declared successor profile, takes precedence. Re-selecting the mode that's already saved (`rig seat set-permissions <seat> --mode <same>`, or the same Codex policy again) now records that choice and its audit event, so it also wins over the carried policy. A missing, stale or unsupported observation keeps the existing launch behaviour, and both JSON and human output say the permissions weren't verified or carried. The result reports policy emission apart from native equivalence, which wasn't tested with a real rebuild. Fresh launches are unchanged.
Cost: source +206/-12 (9 files), tests +193/-4 (5 files), docs +6. Per Codex rebuild: two process observations, a bounded record read and one indexed lookup. A carried policy gets no fresh-only grants, so work that needs them takes an explicit permission choice. No new setting, dependency, migration, confirmation or refusal.
Rebuilding, handing over or freshly launching a seat stopped every active reminder its retiring occupant had registered, whichever seat each one woke (#1775). So an operator's reminders for other seats' parked work died with the operator's occupant, and that work lost its timed backstop.
Now a swap stops only the old occupant's jobs that would wake its own seat. Each job is judged by its delivery target, taken the way the policy engine takes it (one shared helper: a context-usage job's stored target, otherwise the policy's spec target), and that target counts as this seat when it's the retiring or successor name or any of the seat's current logical or physical addresses. Reminders aimed at other seats keep running, as reminders aimed at a handed-over seat already did. The generation scope and the park-timer exemption are unchanged, and there's no registration refusal or alias ban.
Known limit: a workflow keepalive picks its recipient when it fires (the workflow's current frontier owner), so a swap still judges it by its stored or spec target; a keepalive the retiring occupant registered with itself as fallback while another seat owns the frontier still stops. A follow-up makes the swap keep workflow keepalives, which are bound to the work.
Cost: source +80/-23, tests +89/-15. No new refusal, setting or migration.
A person can now choose, per seat, how much automatic input may type into that seat's terminal (0.7.1 slice 21). The levels ride the existing `rig seat set-typing-guard --mode`: `off` (always interrupt; the agent-seat default, unchanged), `openrig-urgent` (OpenRig's own messages and anything urgent), `urgent-only`, and `hold` (full quiet). A terminal member with no setting defaults to `sends-only`. Anyone can mark a message urgent, with `rig send --urgent` or a queue row at priority urgent or critical, and it shows as `Urgent (marked by X):`. Held input stays readable and is never replayed, and a queue row lands whatever the level; a held wake reads as held, not stuck.
One admission rule inside the existing guard decides each input, and what triggered a wake decides its source: the park timer, ladder retries, auto-unpark and OpenRig-created rows are OpenRig's. Send, broadcast, queue create and the cross-host forward never take those markers from a request; the trusted local outbox recorder accepts caller tags by design. Urgent keystrokes for an explicit prompt answer go out exactly, with no label, and an SSH `--host` send forwards `--urgent`. Queued wakes are combined only when each member, admitted at the seat's level, has the same outcome, so a returned result and its waiter's resume still go as one send. Draft protection and the permission-prompt guard are unchanged; at the two middle levels, a draft holds even an urgent override.
Visible changes: an urgent or critical queue row's wake now starts with `Urgent (marked by …):` at every seat, defaults included; for existing `hold` users a held wake is no longer re-nudged or reported stuck, with one held notice instead; lifecycle work is refused at the middle levels as at `hold`; an older daemon types OpenRig's notices at an explicitly set `sends-only`.
Known limit: if the seat's level changes while a combined wake waits for its delivery lease, that one delivery uses the level read at grouping, so one routine wake can type at a newly chosen `urgent-only` or one urgent wake can be held behind a routine one. It needs correlated wakes of different urgency, a busy lease and a level change in that window; the follow-up rechecks membership at the send boundary.
Cost: source +275/-59, tests +559/-46, docs +1/-1. One flag and three `--mode` values; no migration, new refusal or error code.
Adding or freshly launching a managed Claude seat rewrote the worktree's own `.claude/settings.local.json` permission mode to `acceptEdits`, overwriting a person's `auto` or `denyAll` (#1774). The shared Claude settings fragment carried that value, and its scalar won the merge. A managed session never needed it, because every launch passes its own mode flag, which outranks local settings.
Now the fragment no longer carries `defaultMode`, so a person's mode and rules are left as they were. Because the daemon's Claude permission check used that file as the session's mode, configuration now follows launch-mode precedence and keeps the file's default in the detail. A seat with no mode in the file, or with `auto` or `denyAll`, reads aligned. Native enforcement stays explicitly unverified, and the CLI front door allows only that observation gap when the configuration is a valid launch mode; unreadable, invalid or conflicting settings still keep the TUI closed. The printed Claude resume command now carries the selected mode flag, as the Codex one does.
Files already rewritten by earlier versions aren't repaired; set the intended default back once by hand. Manual Claude starts in a worktree now use the person's own default.
Cost: source +24/-13 plus the fragment's -3, tests +73/-55, README +4/-3. No new setting, refusal or error code.
In the default compact `rig ps --nodes` table, the ACTIVITY cell was 14 characters wide, so an activity with a count, such as `needs-input x1`, ran straight into the WORK column (#1588). The cell is now 17 characters, which fits the count suffix with padding before the next column. Tests pin the padding and the column offsets.
Cost: source +1/-1, tests +36/-1. No new refusal or setting.
A rig always took its spec's `name:`, so one spec could run as only one rig at a time (Discussion #1647). Now `rig up <spec> --name <rig>` launches the spec as a rig called `<rig>`: `--name alpha` and `--name beta` give two rigs, with seats such as `work-worker@alpha` and `work-worker@beta`. The rig keeps that name for `rig ps`, `rig down`, `rig up <rig> --existing`, snapshots and restore. Without `--name`, nothing changes.
The daemon sets `name:` in the spec text in memory, right after reading it (a spec path, a v1 bundle or a v2 pod bundle) and before anything parses it. The file on disk isn't changed. So validation, the running-name guard, the archive of a stopped same-name rig, the per-name import lock, the Compose project name, `rigs.name` and seat names all see one name. No new storage or migration, and no second naming mechanism.
Cost: source +99/-14, tests +689/-5, docs +4/-2. That's about 7 test lines per source line, after the author dropped tests that overlapped others. No new refusal or error code.
A Pi seat with no turn yet in its generation read as stuck, because nothing it posted counted as a sign of life. Now the Pi runner posts an idle heartbeat, and the activity ladder takes it as the seat's liveness signal.
The beat is sent only after the runner's last post was a Stop (or before anything has been posted), so a seat that is working never beats idle; a compaction or turn that ends without a Stop simply leaves the seat unknown until its next turn ends, as before. Any posted Stop restarts the beat clock, so the next beat comes a full interval later. An idle report that carries an interval lapses to unknown after two intervals without a beat. On restart, the latest activity is read from the node's newest 200 records before the session filter, so the read stays bounded however long the node's history. Activity posts now start only after the previous one settles, so a beat in flight can't arrive after a turn's first active post and make a send waiting for idle type into a working seat. Each post keeps its 1.5 s timeout, so a slow daemon delays the posts behind it by that much each, and a failed post never blocks the next.
Cost: source +85/-20, tests +242/-1, docs +7/-1. One 15-second check timer per Pi seat, and about 480 idle beats per idle seat per day (one every 180 s). No new refusal, setting or dependency.
After a parent recovery on 2026-10-22, 11 Codex seats came back bound by `rig reconcile-session --no-launch` with empty resume metadata, and nothing in reconcile's result said so; only the operator's own receipts showed it. The shared capture method swallowed its outcome.
Now that method returns the adoption capture outcome, and reconcile reports, in text and in `--json`, whether future resume metadata is recorded, missing, unconfirmed or not applicable. When it's incomplete, the output points to the existing audited `rig seat set-resume-token <session> --token-stdin --reason`, to use after confirming the live conversation's identity. Binding success still succeeds, and the bind and create-and-bind callers ignore the additive return as before. No token is copied from a predecessor, no native input or probe is added, and continuity stays unverified: "recorded" means valid-shaped metadata exists. A legacy generic `probe_timeout` reads as not obtained, not as a measured timeout. Tokens and exception details never enter the result.
Cost: source +50/-5, tests +84/-3. One indexed session-row read after the existing capture. No new refusal, setting or error code.
A seat running on a model nobody chose is announced once per occupant generation, but the monitor kept that settled set only in memory, so every daemon restart announced the same unchanged divergence again.
Now the proclamation carries its content-derived occurrence id (node, generation, pin and effective model), and the existing `seat.model_divergence` event stores it. Before proclaiming, the monitor asks the daemon's event log whether that occurrence was already received, with one query on the existing `(node_id, type, seq)` index. Only an event where some channel delivered or retained the alert counts. One whose channels all failed or were deferred is proclaimed again after a restart, as before. A changed effective model, a changed pin or a new generation still alerts. If the log can't be read, the monitor proclaims and logs why. No new table: the event gains one optional field, and older events simply don't match.
Cost: source +36/-7, tests +76/-0. No new refusal or error code.
A second daemon can open another home's database without anyone noticing: on 2026-10-22 a test daemon inherited OPENRIG_DB, opened the parent's database, and its startup reconcile marked sessions detached there. Nothing said which database it had opened.
Now a starting daemon warns, naming both paths, when its database selection resolves outside OPENRIG_HOME, and the advice names --db, db.path, OPENRIG_DB and RIGGED_DB so the operator can correct whichever override chose it. It also warns when another observed holder of the database is a live daemon. A matching daemon.json's path, mtime and recorded home are labelled as recorded, never as the live holder's current home. `rig status` shows the boot observations in text and in an optional `daemon.databaseWarnings` JSON field. Explicit overrides are still honoured, and startup, reconcile and writes continue: these are diagnostics only. No warning is not proof of exclusive use.
Measured on macOS with Node 22: about 58 ms added at boot with no other holder, 79 ms with a live holder. Holder inspections are capped at four and reuse daemon.json, open-file and health evidence. No dependency, lock or schema.
Cost: source +85/-11, tests +80/-0. No new refusal or error code.
Studio's Projects list needs each project's missions with counts and slice statuses, and the only read was `GET /api/scopes?project=<id>`: about 2.0 MB and 0.6-1.8 s for openrig, holding the daemon's healthz meanwhile. Measured first: readiness is about 90% of the bytes, and proof pairing plus readiness most of the time; the PROGRESS.md narrative reads the plain list discarded cost about 7 ms.
So `?counts=1` returns each slice's `dirName`, `id`, `displayName`, `status`, `stage`, `locks` and `sourcePath` from one node-file read, with no readiness or proof (about 171 KB). `detail=1` wins if both are given. The default list keeps its exact response shape, stops reading PROGRESS.md for the narrative it already stripped, and drops a repeated `workSource` call that returned the same path. The rows come from a new `projectSliceHeader`, which `projectSliceScope` now starts from.
Cost: source +53/-17, tests +85, docs +6. No new refusal; one added query parameter.
An idle Claude seat with Claude's update banner (`Update available! Run: …`) or a `/clear` suggestion as the last line below its mode bar read as `unknown`, so `rig send --wait-for-idle … --verify` waited to its deadline and didn't send. The reader took that line for the footer, so it found no complete input frame, and the empty prompt sat outside the last-three-lines fallback. Now both lines join the existing list of Claude status warnings the reader steps over. The existing rules decide everything else: a warning is never idle evidence by itself, and busy, asking, draft and selection screens still win before idle. The input-box reader shares the same anchor, so it agrees.
Limits: the below-the-bar `/clear` placement comes from the report, with no capture of it; a hint that wraps onto two lines still reads `unknown`.
Reported by @geoff-dhango on #1641.
Cost: source +4, tests +24/-1. No new refusal or flag.
Refs #1641
`rig create x --runtime terminal`, and `rig grow` with a terminal runtime, paired the terminal runtime with the default orchestrator agent, so the generated spec failed with a wrong-agent or profile error. Now the shared default-agent helper takes the runtime: `terminal` gets `builtin:terminal` with `profile: none` before any library lookup, and every other runtime keeps today's default. Both grow paths (a new pod and an existing pod) share the same member generation. A runtime with no shipped default says so and points to `rig up <spec>`.
Found by T4 in a desktop-join dry run on 0.6.14.
Cost: source +8/-7, tests +36/-0. No new flag or refusal.
In the web UI's Files editor, every changed background read (a focus refetch, or the invalidation after a save) reset the textarea, so text being typed could be lost. Now the editor keeps the draft together with the revision it was based on. A clean editor still follows background reads. A dirty draft keeps its text and base until save, Cancel or Refresh. Saving sends the base revision, so an external edit returns a conflict instead of being silently overwritten, and a successful save moves the base to the written revision.
Cost: source +31/-17, tests +109/-0. No new refusal or flag.
Contributed by @Nikhi00718.
`rig stream watch` buffers items that arrive during its initial replay. The handler switched to direct live writes before draining that buffer, so while each buffered item was being written, a newer arrival could be written ahead of older buffered ones, and a watcher saw items out of emitted order around connect time. Now the switch happens after the drain, and arrivals during the drain join its tail. This is the shape the chat watch already uses. It applies to both `/api/stream/sse` and `/api/stream/watch`; replay selection and size are unchanged.
Cost: source +3/-1 (one moved line and a comment), tests +27/-0. No new refusal or flag.
Contributed by @Nikhi00718.
`rig usage cost` grouped each seat's context samples by copying the whole growing array for every row, so the work grew with the square of a seat's samples. The projection runs synchronously in the daemon; with 20,000 samples for one seat (about seven days at the default 30-second cadence) a call took about a second. Now each row is pushed onto one array per seat. The SQL order (`seat_session`, `id`), seat isolation and the `seatSession` filter are unchanged.
Cost: source +5/-1, tests +23/-0. No new refusal or flag.
Contributed by @Nikhi00718.
Scrolling a terminal view back to live repaints the current screen with two tmux calls, while the tail keeps reading the pipe file and sending new bytes. Output the tail read between the screen capture and the snapshot being sent used to reach the viewer and then be painted over by the snapshot: a brief flash. Now `scroll(sub, 0)` keeps that subscriber out of the live fanout until the repaint is sent, so those bytes are skipped instead. The viewer ends with the same screen either way. Output that tmux wrote before the capture but the tail reads after the snapshot still arrives after the snapshot; this change doesn't reorder the stream.
Cost: source +8/-2, tests +45/-0. No new refusal or flag.
Contributed by @Vaishnavi220506. The wording here replaces the PR title's "keep ... ordered", which the review found overstated.
A new agent's first message is labelled as generated context, not a task. A fresh identity-only start wraps its identity block as "OpenRig-generated identity context (not a project task)" through "End of generated identity context.", then asks the agent to run `rig whoami --json` and reply with its identity and readiness for an assignment, and says repository history, tool descriptions and its own replies are not assignments. A start with a bundled task, a restore obligation or a challenge keeps its existing flow. This is guidance aimed at agents that invent a request at startup, not a demonstrated cure.
The longer first message exposed a test-fixture limit: the stub runtime never read stdin, so the identity message plus later sends filled its terminal's unread input queue and a verified send to a narrow pane read unverified. The stub now drains stdin; send verification and its assertions are unchanged. Limit: if the stub can't write its READY sidecar, it now waits instead of exiting 1 (test stub only).
Cost: source +9/-2, tests +13/-5. No new refusal, setting or send.
On Ubuntu 22.04, the packaged tmux is 3.2a, which exits its whole server when asked about a pane or session that has just closed. OpenRig's seat checks could send that query when a seat's session closed, stopping every agent on that tmux server. A new entry in getting-started's restart section, beside #980's, says what happens, how to tell (`tmux -V`), and what to do: put tmux 3.4 or later first on `PATH` (for example by building a current release), then restart the daemon from a shell that sees it. It says newer versions weren't tested against this crash.
This describes released versions. #1755 stops the crashing query on main, so the entry gets a "before 0.7.1" qualifier at 0.7.1's docs refresh (recorded in that mission's notes).
Cost: docs +18/-0.
Refs #980
On Ubuntu 22.04's packaged tmux, 3.2a, a seat's session closing no longer makes OpenRig's seat-activity check ask tmux about it by name. tmux 3.2a exits its whole server on that query (`display-message -p -t <vanished target> '#{window_activity}'`), which stopped every other agent on the server.
The 1 Hz sweep reads every session's window activity in one `tmux list-windows -a` call, then asked per target about each seat the batch lacked. A seat is missing from the batch when its session has closed, which is the crashing query. Now, when the batch was read, a seat it lacks has no activity signal for that tick and no per-target query. For a session that does exist, the per-target read returned the same non-timestamp the batch left out, so no signal is lost. With no batch (tmux couldn't be listed), and for a direct `pollSeat`, each seat is still read per target.
Limits: the race is narrowed, not gone, for a session that closes between the batch read and the same tick's other tmux calls. The structural-activity capture (`capturePanesContent`) also names each due seat's session before consulting its listing; whether those commands crash 3.2a is untested.
Cost: source +7/-5 (the logic change is one condition; the rest is comments), tests +4/-3 (one existing assertion). No new refusal or flag.
Refs #980
`rig fork`'s own help examples pass `--rig openrig-delivery`, a rig name, but `--rig` went to the daemon as an id, so the copied example failed with `rig_not_found`.
Now `--rig` resolves through the CLI's existing `resolveRigHandle`, as `rig expand`, `rig edge` and `rig add` do: an id matches first, then the one active rig with that name. A name two active rigs share stops with `rig_ambiguous` and names the ids, with no fork request sent. A handle that matches nothing, or a rig list that can't be read, passes through as before, so the daemon answers for that exact id. The daemon's fork route is unchanged.
Cost: source +11/-2, tests +34/-1, docs +1/-1. No new flag; the one refusal is the shared resolver's existing `rig_ambiguous`, for a name that failed before.
Refs #1681
Working the documented way (ssh to a server, then run `herdr` there), `rig tui` → TERMINALS → open a rig now places the view in the current Herdr session. Before, it said "This is an SSH session; no desktop window is opened over SSH" and opened nothing, because a Herdr server started from an SSH login passes the SSH variables to every pane, and the SSH check ran before the check for a Herdr pane.
Now the CI check comes first, then the current-Herdr check (which still requires the caller's Herdr socket to match the daemon's), then the SSH refusal. The current-Herdr path places the view through Herdr's own socket and never opens a desktop window, so opening a NEW desktop window over SSH still refuses as before.
Cost: source +1/-1 (one line moved), tests +7/-4. No new refusal or flag.
On a host where the daemon's own endpoint answers but a secondary listener's self-check stays inconclusive (#1714: Tailscale up, the check returning ECONNRESET), `rig daemon start` now starts. It records the daemon as a normal start does, so `rig daemon stop`, `status` and the next `start` work, and it prints `Warning: daemon started (pid …), but its listener on <host> could not be verified …` to stderr. After #1716 the daemon survived but the command failed, and stopping or restarting it needed a manual cleanup.
A listener that is definitely missing still fails the start and cleans up, now also when another listener is uncertain at the same time. The primary endpoint must still answer with the spawned process's own id in the last round, and an uncertain primary keeps #1716's behaviour.
Known limit: the start still waits out its retries before warning, about 20 seconds (about 40 when each check times out). The TUI's "restore everything" path runs a plain `rig daemon start` with a 30-second timeout, so in the slower case it can stop the start before it records the daemon; the TUI's other two start paths bind loopback only. That isn't a regression: main waited as long and then failed.
Cost: source +21/-14 (1 file), tests +102/-25 (3 files). No new refusal, flag or error code.
Refs #1714
Inspecting a bundle before installing it now shows when a Claude member brings its own agent, prompt file or plugin folders. They appear under "Told files" (in the archive, or unknown before launch when the archive doesn't hold them) and under "Writes" as copies into that seat's own storage, never into the project. A member's plugin folder also appears under "Also runs" with its hook commands, the same way an agent spec's plugin does, because it loads like any plugin.
A plugin folder's copy is now named by one shared rule used by the schema's clash check, the launch and this view, so the view names exactly where the launch copies it (`my review` becomes `my_review`). One limit: on Linux and macOS, a folder whose name contains a literal backslash is now named by its last backslash-separated part at launch too, as the schema's clash check already did; a seat with an older copy gets a fresh one under the new name.
Cost: source +33/-3 (3 files), tests +22 (1 file). No new command, view, refusal or error code.
When a seat's storage under the OpenRig home has a link on the way (for example, a moved `state/seats` that left a symlink behind), `rig up` now starts that seat instead of failing it. A one-seat rig used to fail outright. The seat starts without what it couldn't place, and the result names each loss: `seat_skills_not_delivered: <skill ids>: …; the seat starts without these selected skills`, and one `seat_launch_addition_not_delivered` line per agent, prompt file or plugin folder it asked for. Nothing is written through the link, and nothing falls back into the person's project. A seat started through the startup path already behaved this way.
Only that linked-storage refusal changes. Any other skill reconciliation error still fails the seat, and so do a missing or invalid addition source and a storage refusal whose storage isn't itself linked.
Cost: source +17/-5 (1 file), tests +64/-4 (1 file). No new refusal or error code; two warning codes name the losses.
`rig edge add` and `rig edge remove` on a `delegates_to` edge now say when the change moves routing. That covers two cases: which seat a seat's work escalates to (its chosen orchestrator), and whether the rig still has a single representative, which is where a person's Slack message in the rig's mapped channel goes unless that channel names an inbound seat. Before, the receipt named only the edge and its rollback, so an agent could change where work routes without being told.
The change reads the queue's own resolvers before and after the write, in the same transaction and read-only, and reports any difference. The JSON receipt gains an optional `routing` field, and the plain output prints one `routing:` line per move. A change that moves nothing, and every other edge kind, says nothing extra. Nothing is refused or confirmed, and the resolvers themselves are unchanged.
Cost: source +53/-1 (daemon +42/-1, CLI +11), tests +84, docs +9. No new refusal, error code or flag.
Refs #1203
A Claude seat whose `launch-additions.json` was hand-edited, or written by another OpenRig version, now still launches, restores and hands over. Before, a record that wasn't the shape OpenRig writes (for example `{}`) made that one seat's launch fail with a TypeError. Now it launches without its additions and says why: `seat_launch_addition_refused: … isn't a launch additions record OpenRig can read (not JSON, or not the shape it writes) …; launched without it`, the same way a missing addition already behaves.
`readManifest` accepts only a record in the shape OpenRig writes; every launch, restore, handover and seat-view reader goes through it. Any other record in the seat's storage, including unparseable JSON that used to be skipped silently, is named in the existing refused list. A correct record behaves as before.
Cost: source +23/-3 (1 file), tests +26/-8 (1 file). No new refusal, error code or flag.
Since #1008, a team seat's skills live in its own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, not in the project's `.claude/skills` or `.agents/skills`. Several pages still taught the old folders, so an agent orienting from them looked in the wrong place.
Now they say where each runtime's skills go: Claude's into the seat's generated plugin (`plugin/skills/<id>`, shown as `openrig:<id>`), Codex's into `skills/<id>` beside its catalog, and the project folders only for a seat without that storage. `rig skill loadout --seat <session|self>` is named as the way to see a seat's own skills. Changed: the agent spec's skills, plugins and discovery lines, two architecture pages, the test-layers note, the shipped `forming-an-openrig-mental-model` skill (with its digest pin), and the public capability map. Stale source citations in the adapters page are refreshed, and three code comments that named a nonexistent `seat_material: seat` key now describe the seat's own skill folder.
Cost: text +47/-29 (docs +25/-14, shipped skill +13/-8, capability map +5/-3, digest +1/-1, code comments +3/-3). No behaviour change.
Refs #1008
`rig daemon start` no longer kills a daemon whose own endpoint answers just because its Tailscale listener self-check came back indeterminate (for example, ECONNRESET). That was #1714: on a host with Tailscale up, every start stopped a healthy daemon.
Now an indeterminate check on the last retry keeps the daemon running and its start reservation, and the command fails with "listener verification is incomplete", naming the daemon's PID, `rig daemon status`, `daemon-start.lock` and `daemon.log`. A listener that is definitely missing still fails and cleans up, as before.
Known limit: the kept daemon isn't yet a fully managed one. `rig daemon stop` refuses because no daemon state was written, and a new `rig daemon start` refuses while the reservation remains, so restarting needs a manual stop of that PID and the lock archived. #1714 stays open for a start that succeeds with a warning when only a secondary listener is uncertain.
Cost: source +7, tests +33 (2 files). No new refusals or error codes.
Related to #1714
A Claude member in a rig spec can bring its own agent, an appended system prompt file and plugin folders, and that seat (only that seat) launches with them, every time it launches:
members:
- id: reviewer
runtime: claude-code
agent_file: claude/agents/reviewer.md
append_system_prompt_file: claude/prompts/house.md
plugin_dirs: [claude/plugins/review]
- The three keys are Claude-only and resolve relative to the spec. OpenRig copies the files into the seat's own storage, beside its skills plugin, and never into the project.
- Every Claude launch passes the copies: fresh, resume, restore, handover, first-start retry and fork. They go through `--agent openrig:<name>`, `--append-system-prompt-file` and `CLAUDE_CODE_PLUGIN_DIRS`. A seat without additions launches exactly as before.
- The fresh-launch plan, the seat view and `rig skill loadout --seat` list a seat's additions. A missing or colliding one fails that seat by name, and `rig up` prints each failed seat's error.
- Identity proof reads `--agent` and the prompt file. A resume reports whether the conversation started with the current prompt (`system_prompt_pending` or `system_prompt_unknown`).
- `rig fork` copies the source seat's additions into the new seat after it's validated, and says when it carried only part of them. If copying fails partway, the files already written stay in seat storage, and the command reports them.
Native checks ran on an earlier head of this work, not on this one; a native fork is untested.
Cost: source +766/-24, tests +1081/-4, docs +3 (48 files).
Refs #875
A Claude seat can now name an Advisor model in its spec (member, then profile, then agent defaults). OpenRig stores it with the seat and passes `--advisor` to Claude on fresh launch, resume, fork, restore and handover. It's session-scoped, so it never changes a sibling's shared settings, and Codex and other runtimes never receive it. Both process-argument parsers read `--advisor` as a valued flag, so its value is never mistaken for a session ID.
A seat whose first start failed before the Advisor existed still retries. When the node recorded no Advisor, the retry names none, the spec now resolves one and the agent is unchanged, the retry launches without one and stores none. Any other Advisor difference is still refused, with nothing launched.
This finishes #1163 by @Aschenbath (the Advisor field, its resolution and the launch wiring). On top of it: the narrow retry correction, the migration renumbered to 125 after 124, and three merges of main, keeping main's fork session ID, seat effort, startup layers, member env and per-seat plugin folders beside the Advisor.
Cost: source +150/-25, tests +454/-10, docs +34/-1 (28 files). No new refusals or error codes.
Refs #1163
Refs #1152
Co-authored-by: Nash Aschenbath <[email protected]>
Delivery results and queue records still carried before/after input-box diagnostics after the verification decision was made. Remove that reporting and persistence path, including CLI box descriptions, while retaining the opt-in verification setting and all decision-bearing readers. Historical diagnostic explanations are no longer available; existing database columns remain for compatibility.
Update the transport documentation and preserve the setting-toggle test's second-send assertion. Eight files, net 533 lines removed.
Validation: daemon and CLI builds passed; 352 focused cases passed before the documentation/assertion followup, then the affected T42 case passed (218 skipped). Full suite and live opt-in sends were not run.
`rig watchdog register` takes `--threshold-tokens <n>` and `--threshold-percent <n>` for the context-usage watch, so a seat's continuity action can fire on a token count or on a share of its context window (#1180).
When the watch can't read its sample (unavailable, stale, or no token count), it tells the target once per reason for that occupant, instead of repeating a loud skip. A notice never counts as a threshold fire and never runs a continuity action, so a later fresh sample still fires.
This finishes #1198 by @akushonkamen: the two thresholds, their units and the migration. On top of it:
- the one-time notice, banked only when delivered, so a failed one retries;
- the migration renumbered from 102 to 124, and declared with its sibling watchdog migrations in the test database's curated exclusions;
- the two flags in `cli-reference.md`.
Cost: source +425/-41, tests +288, docs +1/-1 (13 files). No new refusals or error codes.
Refs #1198
Refs #1180
Co-authored-by: akushonkamen <[email protected]>
`rig edge add` and `rig edge remove` change one edge on a running rig. Someone who replaced a seat and lost its cross-pod edges (#1193) can put them back without taking the rig down; `rig add` carries only pod-local edges.
- `add` is idempotent: a repeat returns the existing edge. It refuses a launch-dependency cycle (`delegates_to` and `spawned_by`, in launch and restore order) and takes `--plan` and `--json`.
- `remove` takes an exact edge ID within the named rig. A repeat changes nothing and reports `edge_not_found`.
- Stuck-row routing and `rig whoami` see a new edge at once. Restore reads the selected snapshot's edges, so take `rig snapshot` after a repair, as the command says.
- A flat legacy spec now accepts the same five edge kinds as a pod-aware one, so a repaired rig's export still imports.
This finishes #1203 by @Aschenbath (rig names, rig-scoped removal, the request checks, the immediate transaction, the snapshot note, the docs, the #1193 journey and JSON errors when the daemon is down), built on #1034's single add and remove. Their batch file is left out, as our review on #1203 asked.
Cost: source +364/-6, tests +538/-5, docs +60 (15 files). New error codes, on the new command only: `invalid_kind`, `edge_cycle`, `edge_not_found` and `edge_route_missing`. None refuses anything that works today.
Refs #1203
Refs #1193
Co-authored-by: Nash Aschenbath <[email protected]>
A workflow trace can now return the whole trail on request: `GET /api/workflow/:id/trace?full=true` reads every entry, newest first, in the same order as the default read. Every trace response also says `trailCompleteness`: `complete` when nothing was left out, and `recent` when the default's newest 50 cut something.
This finishes #1258 by @psbanka: their `full=true` flag, `continue(instanceId, { fullHistory })` and `trailCompleteness`. On top of it:
- The full read uses the default's query (`closed_at` newest first, then recorded order) with no limit, so the default's 50 are always the full read's first 50. Before, it read in recorded order, which differs when a later entry records an earlier close.
- The default reads 51 rows and returns 50, so `recent` means an entry was actually cut. Before, a 3-entry trail read `recent`.
Cost: source +9/-3 in 2 files, tests +78 in 2 files; the default read fetches one extra row. No new refusals or error codes.
Refs #1257
Refs #1258
Co-authored-by: Peter Banka <[email protected]>
Team skills now live in each seat's own storage instead of the shared project. A Claude seat gets its skills as a generated plugin handed to Claude through `CLAUDE_CODE_PLUGIN_DIRS`, with no new launch argument; a Codex seat gets its own catalog and router. Storage is keyed by the rig and logical seat, so a seat keeps its selection across continuation, and the skill audit reports each seat's skills with where they came from. An optional, default-off setting (`skills.router_line.enabled`) adds one bootstrap line to the project's guidance.
A seat's storage isn't written through a link: whatever is there is left alone, and OpenRig names the selected skills it couldn't deliver (`seat_storage_refused`, and the seat catalog, router and plugin notes) instead of falling back to the project; a launch whose loadout is required stops there. The Claude plugin path needs Claude Code 2.1.280 or newer; OpenRig doesn't enforce that version.
This builds on #1008 by @puniserv: the skill loadout location (`SkillLoadoutLocation` in `skill-catalog.ts`) and the per-seat audit (`skill-discovery.ts`), with their tests, are their code, and the generated plugin's layout is adapted from their `claude-seat-material.ts`. As agreed on this pull request, it leaves out the `seat_material` spec key and its migration, the extra launch arguments, and moving settings, MCP config or guidance out of the cwd.
Cost: source +1786/-55, tests +2422/-46, docs +2/-1 (48 files). The one new refusal code is `seat_storage_refused`, for a write through a link out of the seat's own storage.
Co-authored-by: Adam Dmitruczuk <[email protected]>
Someone setting up a clone with a separately packaged npm 9, such as Debian's `npm` package (9.2.0), hits a failed `npm ci`: `ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`.
CONTRIBUTING's setup paragraph now says to use the npm that comes with your Node, which is npm 10 or newer for Node 22 and 24, and shows how to check (`npm --version`). `engines.npm` is unchanged.
Text only: `CONTRIBUTING.md` +3/-1.
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).
After three consecutive failed refocus attempts, refocus reports the failure to the stream with `rig stream emit`. On a seat whose PATH has no Node, that report couldn't start, so the failure stayed local and the seat's stderr said the report was unconfirmed.
Now the report uses the same child environment refocus's other `rig` calls have had since #1728: the seat's PATH with the running Node's directory appended. `recordRefocusResult` takes it as an optional `env`, defaulting to `process.env`, and refocus is its only caller. When the report is sent, the failure count, the third-failure threshold, the retained issue ID, the occupant key and the deadline are unchanged; the episode still resets after a successful refocus.
The standalone `compose.py` and `trace-to-root.py` scripts are out of scope. Nothing is refused, and there's no new setting. Native Claude hook loading and a live stream write weren't run.
+15/-8 in 3 files: source +3/-3, tests +12/-5 (one existing report test, now also run with an env-node `rig` on a PATH with no Node).
#1727 let the openrig-core plugin's Claude hooks start without Node on the PATH, but two of the commands they start still ran `node` or `rig` from the PATH: the pre-compaction hook's restore-packet step and refocus's `rig` lookups. On a seat whose PATH has no Node, compaction left only manual-recovery text instead of a restore packet, and refocus fell back without its content, work and configuration lookups.
Now the pre-compaction hook runs its restore-packet generator with the hook's own Node (`process.execPath`) and the same arguments, so the packet and its pending marker are written. Refocus gives its four `rig` calls and its Python trace a PATH with the running Node's directory appended after the seat's own, so a `rig` that starts through `#!/usr/bin/env node` works, and any `rig`, Python or Node the seat's PATH already finds still wins.
Limits: `rig` and Python must still be installed. Refocus's report of repeated failures (`refocus-health.cjs`), and the refocusing and operating-model skill scripts when run directly from a minimal shell, still need Node on the PATH; that's a follow-up. Nothing is refused, and there's no new setting or step. Native Claude hook loading wasn't run.
+43/-10 in 5 files: source and plugin assets +9/-6, tests +33/-3 (3.67 test lines per added source line), regenerated skill digest +1/-1.
On a machine where Node isn't on the PATH a Claude seat starts with, the openrig-core plugin's hook commands couldn't start, because each one ran `node` from the PATH. OpenRig's generated settings already pinned Node (#1545); the plugin's own manifest didn't.
Now all 11 of the plugin's Claude hook entry commands start with the daemon's own Node through the existing `OPENRIG_NODE` variable. Fresh launches, managed Claude launches, and handovers of seats with a recorded working directory now pass it. Without it, a command falls back to `node` on the PATH, as before. Refreshing a seat's settings recognizes OpenRig's own relay and status-line entries when someone has repaired them by hand with an unquoted or single-quoted absolute Node path, and replaces them instead of adding a second relay; commands with shell expansion, wrappers or other hooks stay the user's.
Limits: commands the hooks themselves start still need Node on the PATH: the pre-compaction hook's restore-packet step runs `node`, and refocus runs `rig`. Existing seats aren't relaunched or repaired; they pick this up at their next launch. An older daemon with only the new plugin, or the new daemon with an older plugin copy, still needs Node on the PATH. A hand repair written in double quotes isn't recognized, so it stays beside OpenRig's relay and each event is posted twice, as before. A seat whose activity hooks are disabled in its profile, running an older plugin copy on a minimal PATH, loses a single-quoted hand repair at the next refresh until it's relaunched. Nothing is refused, and there's no new setting or step. Native Claude hook loading wasn't run.
+94/-44 in 12 files: source and plugin assets +37/-33, tests +57/-11 (1.54 test lines per added source line).
When an agent advances a workflow step (`rig workflow project`, `route`, `resume` or `abort`), the record now names the agent that made the call. Until now it recorded whatever `--actor-session` the command carried, and the commands OpenRig generates for a step filled in the packet's owner, so a step's history could name an agent that never touched it.
The four routes take the caller from the transport identity the CLI already stamps, through the same helper other routes use. The caller's identity supersedes a declared actor; a caller with no identity is recorded under its declared actor, labelled as claimed; a relayed caller is recorded no stronger than the relay carried it. The closing transition, the next packet and the wake intent carry that provenance, with no new field. When a known caller projects a packet another seat owns, `project`'s response says so in one line; nothing is refused and nothing extra goes to the owner. Generated commands no longer name an actor, and `--actor-session` is now optional.
Two results change. A request with neither an identity nor an actor is still a 400, but its body is now the shared `actor_required` error instead of `{"error": "actorSession is required"}`, so a client matching the old string sees a different body. A request with no identity whose declared actor is only whitespace is now refused. A non-blank declared actor without an identity still works.
The recorded agent is who projected the step, not proof of who built, reviewed, tested or approved the work.
Refs #1710.
+233/-65 in 14 files: source +83/-47, tests +136/-8 (a new five-case route-to-store test), docs 8 lines, and the regenerated skill digests.
After an ambiguous Slack post, replay could retain the message when history could not be read, even if Slack was accepting posts. Retry now follows the existing partial-history path and posts once with possible-repeat context. Successful reconciliation and delivery receipts still prevent a fresh post.
The optional repeat note uses the existing renderer and is omitted when it cannot fit. Retry scheduling is unchanged.
Validation: daemon build passed, followed by 28 focused tests (durability, invalid blocks and reconciliation pagination). QA independently ran the unreadable-history replay case and verified disclosure and re-ack without another post. Full Slack suites, live Slack and a failing baseline run were not executed.
+65/-62 in 6 files: source +36/-29, tests +29/-33.
A mode applied with an end time is watched by a job that runs every 60 seconds. While the mode was still waiting to end, each pass wrote a watchdog history row and an event, about 480 of each for an eight-hour mode.
The waiting skip now returns its own reason, `mode_end_not_yet`, which the engine treats as quiet, the same way the quiet-hours release already does with its own reason. A notice still owed after the mode has ended keeps its recorded skip, because an operator may need to see it. The job's last-evaluation record still updates, so this removes the per-minute history rows and events, not every write.
One test runs the real engine and policy: two waiting passes leave no history row and no event, while an owed notice still records one.
Source +6/-1, tests +30/-1.
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.
2026-10-03 16:58:53 -07:00
2680 changed files with 360585 additions and 19170 deletions
@@ -23,12 +23,17 @@ not by restarting the daemon you live in.
| 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) | Most modules predate 0.6; check each one's`last-verified-against-source` marker against`git log` before relying on it |
| 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.
@@ -56,6 +61,10 @@ stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scrip
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
@@ -23,12 +23,17 @@ not by restarting the daemon you live in.
| 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) | Most modules predate 0.6; check each one's`last-verified-against-source` marker against`git log` before relying on it |
| 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.
@@ -56,6 +61,10 @@ stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scrip
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
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
if:${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
@@ -8,19 +8,76 @@ OpenRig is open-source software for building and running your own network of age
It's the open-source system behind my AI civilization experiments.
**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)
**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.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
**Or step by step:**
```bash
npm install -g @openrig/cli
@@ -29,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
@@ -77,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`
@@ -91,11 +206,12 @@ 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`; cmux defaults `automation.socketControlMode` to `cmuxOnly`, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to `automation`. `--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 cmux settings 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.
@@ -103,20 +219,30 @@ user's home; changing `OPENRIG_HOME` alone does not isolate provider configurati
- **Claude Code:** startup writes workspace trust and onboarding completion.
With an explicit permission mode, it uses the launch-selected `HOME/.claude.json`,
or `<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
retains the daemon's `~/.claude.json` path. In the workspace, `.claude/settings.local.json` receives
writes `HOME/.claude.json`, and also `<CLAUDE_CONFIG_DIR>/.claude.json` when the
daemon has that variable set. In the workspace, `.claude/settings.local.json` receives
the context collector's `statusLine` command and selected activity hooks;
helper scripts live under `.openrig/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource sets
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
selected MCP resources configure those external services. Built-in bootstrap
a user's own status-line command is preserved, so it can prevent collector
installation. Selected settings resources can still replace overlapping keys.
The collector script lives under `.openrig/`, and the activity-hook relay under
the instance's `state/claude-activity-hooks/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource enables
Exa/Context7 MCP entries and leaves existing `permissions.defaultMode` and permission
Permission mode controls native execution permissions; work posture is separate
project guidance. Use `rig policy permissions list|show|current|apply` for rig
@@ -154,9 +304,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.
@@ -177,29 +330,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:
@@ -209,7 +355,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
@@ -237,21 +383,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
@@ -267,7 +415,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).
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
@@ -347,9 +497,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.
@@ -357,7 +507,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.
These are things we'd welcome help with. Most came from the community, and most are small enough for one pull request.
This isn't a schedule or a promise: we release often, and an item ships when a good change for it lands.
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.9
[0.7.9 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.9): it follows through on 0.7.8. A person's typed words on a runner seat's permission ask now reach the agent, each permit OpenRig grants a runner by default is recorded, and a runner can keep a long, quiet turn reading working. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
1. Pick an item and comment on its issue to say you're working on it. If it has no issue yet, open one first.
1. Pick an item and comment on its issue to say you're working on it and coordinate with others. A maintainer can assign
you to make that work visible. If it has no issue yet, open one first.
2. Read `CONTRIBUTING.md`, and ask your agent to use the `developing-openrig` skill. It's a map of how OpenRig is
built, tested and changed.
3. Open a pull request that says what a user gets and how you verified it.
@@ -21,28 +29,20 @@ tie. Feel free to coordinate on the issue first.
## Agents and runtimes
- **Codex seats that can reach the local daemon by default** (#275), with an easy opt-out.
- **Custom Codex providers such as Bedrock** (#194): accept providers that authenticate with an environment key, and
forward their token.
- **Operator recovery for Pi and Oh My Pi seats** (#41).
- **New agent types:** OpenCode (#87, #227, #517), the Cursor CLI (#352), the Grok CLI (#522) and Devin (#44) are
planned on a shared adapter.
Design discussion: #43. Adapter pull requests are welcome once that adapter lands.
- **More harnesses:** we're working out one way for any coding-agent CLI to plug into OpenRig. More on this soon
(design discussion: #43).
## Slack
- **A DM or private channel for single-user setups** (#313).
- **A channel map** (#192): one channel per seat, keyed by channel ID.
## Reliability and everyday use
- **A configurable readiness window for fresh launches** (#182), so seats under load don't time out at a fixed 30
seconds.
- **Draft-aware delivery** (#48): automated sends wait while someone is typing in a seat, or a seat can be set to
inbox-only.
- **A host-aware resource budget for seats** (#80).
- **Predictable `rig up` over a stopped rig with the same name.**
- **A "merged" stage in the project view** for teams that integrate on a branch other than main (#402).
- **Codex seats that leave a repository's tracked `AGENTS.md` alone** (related: #64).
"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.",
# --- layer 2: pinned node (an in-range engines version — see scripts/check-engines) ---
ARGNODE_VERSION=22.22.1
ARGNODE_VERSION=22.23.4
ARG TARGETARCH
RUNset -eux;\
case"${TARGETARCH}" in \
@@ -33,10 +33,14 @@ RUN set -eux; \
"")echo"TARGETARCH is empty — the legacy (non-BuildKit) builder does not populate it. Pass --build-arg TARGETARCH explicitly (build-testbed-image.sh does) or enable BuildKit. REFUSING to default to amd64, which would install wrong-arch Node." >&2;exit1;;\
| `listInstalled` | `(binding)``:144` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)``:147` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding)``:150` | Deliver resolved startup files to the runtime. |
| `launchHarness` | `(binding, opts)``:160` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)``:166` | 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` (`:136`)
and `skillTargetPath?()` (`:141`, implemented by `OmpRuntimeAdapter`,
`omp-runtime-adapter.ts:12`).
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:128–133`)
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:162`).
ForkSource }` (`runtime-adapter.ts:172`).
Per the contract docstring (`runtime-adapter.ts:152–159`) `resumeToken` and
Per the contract docstring (`runtime-adapter.ts:162–169`) `resumeToken` and
`forkSource` are mutually exclusive — if both are provided the adapter **must
refuse** with a clear error, not guess; `forkSource` triggers a fork and the
captured token is the NEW post-fork token, never the parent. `ForkSource` is
| `GET /api/files/asset?root=…&path=…` | Raw asset; single byte-range support (`206`, invalid range `416`), five-minute cache. HTML defaults to plain text; `render=1` opts into HTML rendering. |
| `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`. |
Writes resolve actor/provenance through `resolveActorWithDeferral`. Missing
route dependencies return `503`; unknown roots and invalid paths return
`400`, and failed file stats return `404`. An audit failure can return `500`
even though the file write succeeded, so an error is not proof of no effect.
`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
error is not proof of no effect.
## Progress indexing
@@ -127,13 +145,17 @@ combines resolved workspace settings with `OPENRIG_STEERING_WORKSPACE`,
| Field | Source and selection |
|---|---|
| `priorityStack` | Verbatim `STEERING.md`, mtime, and byte count. |
| `roadmapRail` | `roadmap/PROGRESS.md`checkbox rows; the first unchecked row is marked next. |
| `laneRails` | Progress files under `delivery-ready`, using a depth-3 `ProgressIndexer`; first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows. |
| `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. |
Missing/unreadable sections become `unavailableSources`. `isReady` means at
least one path resolves; it does not guarantee that every read succeeds.
returns `503` when the composer is absent or no source path resolves.
returns `503` when the composer is absent or no source path resolves. Because
`workspace.steering_path` always has a value (default
`<workspace.root>/STEERING.md`), the daemon's composer is normally ready, and
a missing `STEERING.md` appears as a `priorityStack` read failure in
`unavailableSources` with a `200`, not a `503`.
Queue and health state are fetched through their own endpoints.
## Health summaries
@@ -145,12 +167,17 @@ exposes four reads under `/api/health-summary`:
|---|---|
| `/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 running daemon's version, or`unknown` on read failure. |
| `/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. |
`/nodes` and `/context` return `503 health_summary_unavailable` when the rig
repository is not wired.
The [context aggregation](../../../packages/daemon/src/domain/steering/health-summary.ts)
uses **80%** for critical, **60%** for warning, and **300 seconds** for
freshness. A missing usage value is unknown; a missing timestamp has no
freshness. These are constants in this module: they do not follow the
`health.context_pressure.*` settings (defaults 95% and 99%) that the health
detectors use, so the two surfaces can disagree about the same session. A missing usage value is unknown; a missing timestamp has no
freshness sample. The SQL read catches errors and falls back to an empty
sample set, so zero counts alone are not a database-health guarantee.
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:32`;
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:33`;
(`:452`; validate against the v0 schema; non-mutating). Packet shape is the
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
@@ -281,6 +429,8 @@ via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
- `transport-and-transcripts.md` — transcript boundary markers written on
| `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`) |
| `resolve` | for a `blocked` item parked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`): moves it to `in-progress` with required decision/satisfaction evidence in the transition note; typed waits are recorded as blocker resolutions, not human decisions. Qitem dependencies, malformed gates and non-blocked items are refused; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
| `answer` | only for a `pending` ask addressed to the acting person: a choice (`questionId` + `optionId`) through `QueueRepository.recordHumanAnswer`, or their words (`decision`) through `resolveDirectHumanReply`, each with its existing meaning (a partial choice set stays pending; words fill the first unanswered question). Once answered, one reply row goes to the asking seat (`human-answer` tag, `replyTo` the ask, an id derived from the ask) and then the ask closes once with `human-decision-resolved`, the order Slack's answer continuation uses. With neither a choice nor words, a complete question set whose return didn't finish is sent again with exactly its recorded answers. A closed ask answers 409 `qitem_already_terminal` and records nothing; a refused answer is 400 `answer_not_recorded`; 409 `answer_not_closed` means the ask stayed open: its return failed, it changed before its close, or a retry came in other words than the reply already returned (that retry isn't sent). The TUI's Feed sends it |
Each verb is one atomic daemon transaction: the queue mutation via
Each verb except `answer` is one atomic daemon transaction (`answer`'s queue writes are the repository's own, in the order above, then its audit row and event): the queue mutation via
`254122872cf477511514979a4300b695d77cd1f7`. Source paths below are repository-relative.
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. Source paths below are repository-relative.
An npm CLI artifact and a `.rigbundle` have different builders and consumers; neither
source verification nor archive integrity establishes that a daemon has adopted an artifact.
@@ -59,30 +59,94 @@ from live state.
| 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. |
The pod builder collects culture, docs and startup material, preserves file bytes and available
mode metadata, and skips vendoring the terminal sentinel as an agent. Required document
collection failures stop assembly; missing declared skills can instead produce warnings.
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
one and stages a copy of the rig folder in a temporary directory with the chosen runtimes and
profiles, so the author's folder is never changed. With a GitHub folder link, `rig up`,
`rig bundle create`, `inspect` and `install` take the same two options and stage a copy of the
whole checkout (`importGitHubBundle()` in `packages/cli/src/lib/bundle-source.ts`).
`packages/daemon/src/domain/bundle-identity.ts` defines the two identity values:
-`configurationId()`: every member's `pod.member=runtime`, sorted and joined with `,`.
Create computes it from the normalized spec and returns a 400 if the client sent a
different one.
-`packageDigest()`: SHA-256 over the sorted `integrity.files` entries (coverage label
`openrig.package-digest/v1`). It covers every packaged file's bytes; it does not cover
`bundle.yaml`, the junk files the integrity walk skips, or file modes.
Create and inspect responses carry the source, configuration ID, package digest, archive
hash and assembler version.
### Archive checks
`packages/daemon/src/domain/bundle-archive.ts` owns `pack()` and `unpack()`;
`packages/daemon/src/domain/bundle-integrity.ts` owns the per-file integrity map.
Packing sorts entries, uses portable tar metadata and a fixed tar timestamp, and writes a
sibling SHA-256 file. Unpacking checks that digest, rejects archive links and unsafe entry
paths before extraction, requires `bundle.yaml` and its integrity section, and verifies
the extracted file inventory. Hashes establish consistency with the supplied manifest and
digest; they do not authenticate whoever supplied both.
Packing requires a `.rigbundle` output path, sorts entries, uses portable tar metadata, a
fixed tar timestamp and gzip level 9, and writes a sibling SHA-256 file. Unpacking checks that
digest, rejects archive links and unsafe entry paths before extraction, requires
`bundle.yaml` and its integrity section, and verifies the extracted file inventory. Hashes
establish consistency with the supplied manifest and digest; they do not authenticate
whoever supplied both.
The inspect handler unpacks through this same path before reporting the appropriate manifest
shape. A syntactically valid manifest alone is not an inspected archive.
The inspect handler does not call `unpack()`, because it must report a broken bundle rather
than refuse it. It reports a missing or mismatched digest as `digestValid: false`, applies the
same unsafe-entry pre-scan (`collectUnsafeArchiveEntries()`) and extraction filter, and runs
`verifyIntegrity()` to report an `integrityResult`. A syntactically valid manifest alone is
not an inspected archive.
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
@@ -95,13 +159,14 @@ extensionless paths also have content detection.
| Input | Consumer |
|---|---|
| Existing rig name | Existing-rig restore path. |
| 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 and existing-rig-name entries. This is narrower than the single-rig `up` surface.
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
@@ -111,48 +176,107 @@ Bootstrap distinguishes pod-aware and legacy specs, and inspects a bundle manife
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.
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. For a pod bundle,
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 the repository's rig list before bootstrap. The compatibility override
`skipVersionCheck` and name-conflict override `force` are explicit request fields.
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.
After a completed bundle install, the route separately unpacks and routes declared content:
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:
| Manifest content | Destination selected by the route |
- **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. |
The individual implementations are the `bundle-skills-router.ts`,
4. The continuation then runs below the threshold: turn boundary, restore prompt, then a
read-depth audit prompt (each item marked `FULL`, `PARTIAL` or `NOT_READ`), one stage per
poll tick. A seat refreshes its usage sample only when it takes a turn, so when the sample is
stale, unknown or has no percentage and a stage is pending, the monitor drains that stage
without one (`context-monitor.ts:175`, `claude-compaction-enforcer.ts:436`). Only a fresh,
known sample can start a compaction. The sample-less drain reads readiness from the pane alone
and counts a timer-less live status row, such as `✻ Compacting conversation…`, as work
(`session-transport.ts:153-161`); a recent hook showing work or a waiting person still
refuses. It records no width receipt, because the only figure would be the pre-compact one.
An automatic attempt has a 25-minute ceiling that starts once the prep is delivered. A stale
occupant generation, the policy becoming disabled, or the deadline passing stops it. A prep
that clearly wasn't sent is retried at most three times; an uncertain prep or `/compact` is
never replayed.
The restore prompt names a restore-pending marker at
`compaction/restore-pending/<session>.json` in the OpenRig home, which the `openrig-core`
PreCompact hook writes. A per-seat `compaction/post-compact-extra/<session>.md` is preferred
over the global extra file, and an extra file that declares a different seat is refused.
`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
preparation and the idle wait.
The below-threshold continuation checks policy enablement too, except for an explicitly
operator-initiated manual sequence. A known occupant-generation mismatch invalidates the
@@ -202,7 +324,8 @@ queued stages; an unknown generation does not prove a match. Failed or retained
do not advance the stage. The manual trigger shares this continuation while having its
own initiation checks.
Stage, deduplication and cooldown maps are in memory. Startup supplies live generation
Stage, deduplication, cooldown and preparation-attempt maps are in memory; the restore maps
are files. Startup supplies live generation
resolution and a post-restore callback that records a managed-width receipt, so the wider
operation also has persistent effects. Do not infer durable lifecycle completion from
a library read or one successfully queued message.
For a successful advance, queue closure, successor creation, trail entries,
frontier/version updates, durable events, and wake intent staging share the
transactional notification envelope. Terminal delivery happens after the
commit. A committed packet or staged wake is not evidence that its recipient
consumed the message.
commit, and the runtime awaits it, so project, route, resume and exception
responses return after the delivery attempt; a terminal close without its
successor's staged intent rolls back. A committed packet or staged wake is
not evidence that its recipient consumed the message.
The serial path absorbs an identical waiting replay by matching the stored
Each exit closes the packet through the queue's own closure path
(`updateWithinTransaction`, hot-potato rules intact): `handoff` records
`handed-off` with `handed_off_to` the next owner; `waiting` records `blocked`
with `blocked_on` the supplied blocker, or `external-gate` when none is given;
`done` records `done` with `no-follow-on`; and `failed` records `done` with
`denied` and the result note as target (`workflow-projector.ts:1571`–`1631`).
A dependency-graph handoff that fans out to more than one successor, or to
none, closes the packet `done` with `no-follow-on`.
Both executors absorb an identical waiting replay by matching the stored
closure intent; changed wait data is a new decision. A consumed terminal
packet is no longer on the frontier and is refused. `max_hops` constrains
routing relative to the current drive's baseline; resume establishes a new
bounded drive rather than erasing history.
packet is no longer on the frontier and is refused. On the serial path a
routed close moves the frontier to the new packet and leaves the instance
`active`, or `waiting` when the target step is gated; an unrouted `waiting`
keeps the closed packet on the frontier so the keepalive can find its owner;
`failed` sets `failed`, and `done` with nothing left sets `completed`. A
dependency graph is `waiting` while every frontier packet is blocked, and
`failed` when the frontier empties with an unresolved occurrence.
`max_hops` constrains routing relative to the current drive's baseline: a
route that would exceed it becomes a `failed` exit with a `max_hops_exceeded`
note and evidence, the packet closes and no successor is created
(`workflow-projector.ts:419`–`443`); resume establishes a new bounded drive
rather than erasing history.
Presence of `depends_on` selects `projectDependencyGraph`. It uses durable
packet bindings to advance eligible successors and record per-occurrence
failures without treating an unrelated live branch as completed. Inspection
reports unknown bindings rather than inventing a step from trail order.
failures without treating an unrelated live branch as completed. A step's
completion counts only while it is later, in trail append order, than its
prerequisites' latest completions, so a prerequisite re-run through a routed
exit sends its dependents round again (`workflow-projector.ts:1004`–`1019`).
Inspection reports unknown bindings rather than inventing a step from trail order.
The injected [`createWorkflowFrontierPredicate`](../../../packages/daemon/src/domain/workflow-frontier-guard.ts)
lets the queue refuse ordinary terminal closure of live workflow packets.
@@ -144,8 +208,8 @@ to import the workflow domain to evaluate that predicate.
| 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 with a recorded decision and newly resolved owner. 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. |
| `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. |
## Deadlines and exceptions
@@ -157,6 +221,21 @@ claim time is absent). Pending work uses creation time plus **4 hours**.
Blocked waits are not overdue under this evaluator. This is a diagnostic,
not a substitute for packet existence or binding checks.
Instantiate and every routed projection arm a `workflow-keepalive` watchdog
job in the same transaction (one per live packet for dependency graphs, one
per instance for serial specs), evaluated every 15 minutes and quiet until the
step is overdue (`workflow-keepalive-arming.ts:38`). At each daemon start the
boot sweep re-arms keepalives for live instances, re-nudges pending frontier
packets that were never nudged, and surfaces overdue instances
(`workflow-boot-sweep.ts`). Neither path advances a step.
There are three exception classes: `unmapped_failed`, `stuck_overdue` and
`human_gate_trip` (`workflow-exception.ts:34`–`38`). An unrouted `failed`
exit creates one urgent `workflow-exception` queue item in the same
transaction, with evidence `rig workflow trace <instance>` and a body naming
the `rig workflow resume` command; a human gate's own parked packet carries the
`human_gate_trip` identity, so no second item is created.
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time (`domain/rigspec-schema.ts:350–352`); authors may declare it 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 |
@@ -74,7 +74,15 @@ Workspace initialization no longer seeds a getting-started mission (§3).
# Arteries: where a small change has a large effect
@@ -22,7 +22,7 @@ last-updated: 2026-10-01
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 `1347d825`. Files and dependencies move. If
- **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.
@@ -34,14 +34,14 @@ 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). |
| **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/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154;#225 fixes its config-selection part). 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 fixed 30-second readiness window times out under load (#182, open). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). |
| **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 30seconds 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()` | 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. |
| **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: open#112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and open #155 and #195take 090 and 091. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Restore and recovery** | `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`ignores the configured shared-docs root (#130, open). After a fresh relaunch, a stale Codex restore attention flag never clears (#116, open). |
| **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 #195landed 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
@@ -65,7 +65,7 @@ change to an artery meets a real end-to-end check before it merges, not only a r
### Scenario coverage today
At commit `1347d825`. For why the stub cannot reach some of these yet, see "What a stub can and
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
@@ -76,6 +76,11 @@ cannot prove" in [test-layers.md](test-layers.md).
- **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
@@ -88,9 +93,12 @@ cannot prove" in [test-layers.md](test-layers.md).
- **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`launches stub seats through the real launch path. Native Claude
Code and Codex launches are not exercised; the CI containers have no provider CLIs or
credentials.
- **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
@@ -101,3 +109,10 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
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.
- 2026-10-23, F1 (0.7.0 candidate `2fec7382`), launch (a fresh Codex kernel): a fresh Codex kernel stopped at Codex's hook review. Caught at the cut, before publication, by native first-user and daily runs. Missed by the stub layer, which has no real Codex binary. Fixed in #1824.
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
Verified against source commit `30bf2ca06375680f1f8cbe68cc757e212d23dec6`.
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
There are **85 top-level registrations**, **341 registered command objects**
There are **90 top-level registrations**, **410 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,026 explicitly registered option objects**, including the root version
**1,304 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are source counts, not a claim about a deployed release.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
## Reading this reference
@@ -33,7 +33,8 @@ Each table row lists one registered command, its positional arguments,
command aliases, and its **own declared options**. `<value>` denotes a
required positional/option value; `[value]` is optional; `...` is variadic.
An option marked **required** was registered with Commander's mandatory
option setting. Conditional requirements enforced by an action are not
option setting. An option marked (repeatable) collects every value when it is
given more than once. Conditional requirements enforced by an action are not
encoded by this inventory.
Use `rig <command> --help` for descriptions, choices, defaults, and examples.
@@ -49,23 +50,29 @@ route remain the source for result/error shape and side effects.
## Entry points and practical routes
Bare `rig` opens the TUI when both stdin and stdout are terminals. Arguments,
Bare `rig` opens the TUI, the status dashboard rather than an agent conversation, when both
stdin and stdout are terminals. Arguments,
pipes, and redirected streams follow normal CLI parsing.
[`runFrontDoor` and `openMissionControl`](../../packages/cli/src/front-door.ts)
own the entry behavior; [`tuiCommand`](../../packages/cli/src/commands/tui.ts)
owns explicit TUI options. A daemon-down transport result can still open the
owns explicit TUI options; `rig terminal open saved:kernel --window` opens a terminal view
of the OpenRig TUI, the operator and the advisor (in a new tab or window, or the current
Herdr session), and `rig tui --shared` is the dashboard-only fallback when that view cannot open. A daemon-down transport result can still open the
recovery cockpit; it is not automatically a reason to exit before rendering.
| Need | Start here |
|---|---|
| Install, upgrade, or make a first project | [Getting started](../reference/getting-started.md), then `rig setup --help` and `rig doctor --help`. |
| Inspect live state and diagnose failures | `rig ps`, `rig health`, `rig doctor`; [health diagnosis](../reference/health-diagnosis.md). |
| Define/start rigs and seats | `rig spec`, `rig agent`, `rig up`, `rig create`, `rig grow`; [RigSpec](../reference/rig-spec.md) and [AgentSpec](../reference/agent-spec.md). |
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
-`human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription``{ value, source }`.
-`--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
-`--threshold-tokens` and `--threshold-percent` set the plan's own candidate thresholds (the percentage applies when a seat's context window size is missing). They are not the automatic compaction setting: the output labels them as the plan's own and shows `policies.claude_compaction` (`enabled`, `threshold_percent`) separately, for comparison only, or says it couldn't be read.
| 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" |
# 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 `1347d825`
> **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
@@ -39,10 +39,10 @@ to `main`. In the PR description, say which layers you ran and which you could n
|---|---|---|---|---|
| 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-docs-guard.mjs`, `mirror-skills --check` and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| 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`) | Every copy of each shipped skill matches its recorded hash, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | After you edit a shipped skill: edit the same file in every copy that carries it, run `node scripts/regen-edge-digests.mjs`, then the check. The full `npm run mirror-skills` apply needs maintainer-only inputs. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| 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 |
@@ -50,8 +50,36 @@ to `main`. In the PR description, say which layers you ran and which you could n
| 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.
@@ -60,6 +88,11 @@ to `main`. In the PR description, say which layers you ran and which you could n
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
@@ -76,7 +109,17 @@ credentials are supplied.
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
@@ -99,8 +142,8 @@ repository.
- 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 wherever`OPENRIG_GATE_VERDICT`
points). The file holds the `HEAD` SHA, per-leg results and durations, and a note of other
- 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.
@@ -115,8 +158,8 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`,`emit`,`mutate`, `policy`parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
`DOCKER_CONTEXT` is set. The engine must be Linux on amd64 or arm64. The script reads the
@@ -221,16 +292,16 @@ The stub does not fabricate product outputs. It runs the real daemon, tmux and C
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 `1347d825` | What that means for your test |
| 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", 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`); without one it uses `sendText`. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal`, but its native-process proof is limited to `codex` and `claude-code`. | A stub seat doesn't exercise wrapper, managed-launch or native-process-identity behaviour (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub models only the `floor` / `full_bypass` launch posture. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
| 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, managedlaunch, wrappers or native-processidentity (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). |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
@@ -238,18 +309,29 @@ 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.
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
Three scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
and `transcript-reads-addressed-seat` and `capture-returns-addressed-seat` in passing form
only. Two more, `down-stops-every-seat` and `send-renders-in-addressed-pane`, run with the
local runner but not in CI. Those four later scenarios are not in this table; see "Status
today" under [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario). The
other ten scenarios are authored but not yet runnable as meaningful checks:
Seven library scenarios run in CI: `queue-baton-survives-restart` (since #243) and
`refocus-parked-baton` with their seeded pairs, and `transcript-reads-addressed-seat`,
of them. The other nine scenarios are authored but not yet runnable as meaningful checks:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
@@ -258,16 +340,48 @@ other ten scenarios are authored but not yet runnable as meaningful checks:
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `compaction-restore-resumes-role` | Step-time `emit` is unbound | Input-triggered hooks plus evidence specific to one seat |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
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.
`packages/daemon/test/rig-edge-cli.integration.test.ts` uses the same private
scenario daemon and tmux helpers for edge repair. It removes a stub seat, re-adds
its logical ID with the terminal runtime, then restores its cross-pod edges with one
`rig edge add` per edge. It checks repeat IDs and edge counts, refusal without a
write, unchanged sibling node/binding identities and pane PIDs, export,
snapshot capture and exact rollback. It models the cross-runtime replacement
workaround without invoking Claude Code or Codex. The YAML runner has no bound
edge/remove/add verbs, so this journey drives the real CLI directly:
```sh
npm test -w @openrig/daemon -- test/rig-edge-cli.integration.test.ts
```
## If your change touches an artery, add or extend a scenario (or say why not)
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
@@ -335,8 +449,8 @@ 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 `1347d825`, `packages/cli/src/index.ts` registers 85 top-level
> commands. Before picking one up, check `packages/test-system/scenarios/`, the cases in
> 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.
@@ -355,12 +469,29 @@ that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage i
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 four run with the local runner (`run-scenarios.mjs`). The transcript and capture
scenarios also run in the CI job, in passing form only; the down and send scenarios don't.
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` and `daemon` are used by the existing scenarios but have no scenario of their
own. Everything else has none.
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`.
`compaction-restore-resumes-role` covers the compaction round-trip's success path: the
seat's launch script (delivered through `env.stub_scripts`) fires the real precompact and
restore seams in order, and impl's own pane carries the injected directive. It runs with
the local runner, not in CI (container mode refuses `env.stub_scripts` loud and named), and
its seeded wrong-seat-restore negative case is not armed yet.
Before you start, two honest constraints:
@@ -373,17 +504,18 @@ Before you start, two honest constraints:
`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>` with no flags, and the node is the logical
ID (for example `dev.qa`). A stub seat has no resume token, so relaunching it after `down`
stops at `awaiting-decision` and asks for `--fresh`. The `launch` row needs that flag passed
through first.
- 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` | start/status/stop/restart. Port, process and persisted queue identity agree through public commands. |
| 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` | Launch two stub seats. Each consumes its own send. Relaunching doesn't duplicate identities. |
| 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. |
@@ -408,7 +540,7 @@ Before you start, two honest constraints:
| 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 | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| 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. |
@@ -482,18 +614,10 @@ How to make a case useful:
Read these with care. This page notes them and proposes no changes.
- `packages/test-system/README.md`: says the scenarios "DO NOT run" until a runner lands.
The runner exists, and three library scenarios run in CI. Its layout lists `fixtures/*.yaml`,
a directory that no longer exists (the topologies live in `scenarios/`). It also says the
emit behaviours don't exist yet. Launch-script `emit` now exists, but step-time `emit` is
still unbound. `packages/test-system/ci/README.md` is the current description of the CI
scenario job.
- 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.
- `packages/test-system/evals/README.md`: says cases are `cases/*.ts`. They are
`cases/selection.yaml` and `cases/loading.yaml`. Its "build in progress" status line is old.
- `docs/reference/developing.md`: says "There is no external CI at this tip". In fact
`.github/workflows/tests.yml` runs on every PR to `main`, and its `package-tests` matrix
includes `ui`. Its description of `test:repo` leaves out the `generate-context-packs --check` step.
- `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"` |
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
The sample does not implement the input-required and cancellation controls described later. Use a peer that
supports those operations when testing them.
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
Stop the local wait with Ctrl-C or a caller-owned overall deadline; this does not confirm cancellation, so keep the task/context IDs and cancel through the peer when it is reachable.
```python
import asyncio
import json
import os
import uuid
from pathlib import Path
import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import new_text_message
from a2a.types import Role, SendMessageRequest
from google.protobuf.json_format import MessageToDict
Test it again when changing SDK versions, including an awaited external write rather than an immediate callback.
Keep these facts separate:
| A2A observation | What it means for the queue or worker |
|---|---|
| Submitted | The A2A front accepted a task; a row may not yet be created. |
| Working | This front observed the receiving seat's claim. |
| Input-required | A question needs answering; this example leaves the row in-progress. |
| Completed + artifact | The worker's stored result was returned; your assessment still belongs to you. |
| Canceled | This inbound front committed queue cancellation; an outside peer's cancellation says nothing about your outbound row. Verify separately that the worker noticed and stopped. |
| Failed / JSON-RPC error | Retain the actual error and any row effect; do not report success. |
| Disconnect / timeout | The last known state remains all you know. Inspect before retrying. |
There is no one-to-one A2A/queue state mapping. The example does not follow handoffs to another row, so it reports
that limit instead of pretending the successor's work completed. Stopping the front only detaches clients; it does
not cancel its queue rows or stop their workers. Cancel or hand off outstanding work deliberately before stopping
your server. Acknowledged cancellation does not establish that a model provider stopped spending.
## What was exercised, and what remains open
The initial trials used an OpenRig 0.7.0 candidate and SDK 1.1.0 on Linux. Outbound discovery, streamed output and
artifacts used the official Hello World sample. Deterministic peers covered input-required, cancellation, failure
and concurrency. Inbound used a private daemon and a separate no-model queue worker: five artifacts matched the
stored resolutions, two tasks overlapped without crossing IDs, and completed-task reuse left the old result
unchanged. The successful cancellation check also observed the worker stop, beyond receiving the protocol reply.
A later inbound check used an official SDK 1.1.0 client and front, an OpenRig queue row, and Pi 1.4.0 running
`openai/gpt-4o-mini`. One marker answer reached the row's resolution and the client's completed artifact. A
custodian performed the queue writes; model tools were disabled. That run used a 120-second streaming read
timeout and prompt RPC event draining, changed together. It did not separately test the unbounded read used above.
These results do not establish model quality or provider billing. Other framework versions, model tool use,
public deployment and authentication, stream reconnect, durable bridge restart, and cancellation before row
binding were not exercised. The inbound example holds A2A tasks and their row mapping in memory: keep it running for the conversation;
after a crash, inspect retained OpenRig rows before any replacement run. It binds loopback and supplies no public
authentication layer. Use it on your own machine or a trusted private path, not as an unauthenticated public gateway.
rig acp run <row> --cwd <work folder> -- codex-acp
```
**Which versions run.** The binding note on the row names the adapter and its version.
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
loads your Claude configuration, skills included.
- `codex-acp` 2.5.0 depends on `@openai/codex``^0.165.0`, and 0.165.0 was the only release in that range when this
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
the machine's existing Codex login.
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
`codex-acp` 0.16.0's Codex was too old for the model in the check.
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
have the agent ask, so that each request becomes a permission row:
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
make it ask.
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
sweep.
**Answers differ between the two.**
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
and the turn carries on.
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
ends the whole turn, and the outcome is `cancelled`.
**Continuing, and a session that doesn't exist.**
- **Continuing:**`--load-session` continued an earlier session on a second row with both adapters.
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
long and background work weren't checked.
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
isn't a finished shutdown, and it says nothing about further spend.
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
the run's log file, not on the row.
**Not checked:**
- long or multi-tool tasks, and background work;
- concurrent runs;
- `rig acp stop` with a real stop command;
- an adapter that crashes mid-turn;
- continuing after a daemon restart;
- MCP servers.
## Notes for specific agents
- **OpenClaw:**`openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
example, `rig acp run <row> -- openclaw acp --url ws://127.0.0.1:18789 --token-file <file>`. Its permission requests
come from the Gateway's exec approvals.
- **Continuing a session:**`--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Running it in the background: `rig exec`
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
connector that outlives the command:
```sh
rig exec harnesses # the adapter and native versions installed here, and how each differs
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
rig exec cancel <runId>
```
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
The row gets the same notes as with `rig acp run`.
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
twice. `--again` starts another turn once that run has ended.
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
remain is said there too.
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
reads `failed`, with the agent's code and message, and no new session is started either way.
- **The outcome is the agent's report.**`finished` with `completed` means the turn ended; accepting the work stays
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
follows the row's run, including one another caller started.
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
`rig exec harnesses` lists what each harness reads.
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
## OpenRig as an agent in ACP apps
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
`rig acp serve` and talks to it on stdin and stdout:
```sh
rig acp serve --to <member@rig>
```
Without `--to`, the receiver is this machine's `acp.to` setting: `rig config set acp.to <member@rig>`, set once. An
app that starts the agent from the ACP registry passes no arguments, so that's how it reaches your team.
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
with the row's notes as they're written, each with the seat that wrote it. The step's title also shows its latest
note, for apps that show steps collapsed.
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
turn ending says only that the turn ended: read the reply for how the work ended.
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
and the app first shows any earlier row that's still open.
- **A question the team asks you** while a turn is open comes to the app with the ask's own choices, when its row is
tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An app that takes forms (it
advertises ACP elicitation's form mode, as T3 Code does) gets a one-choice form with each choice by its label; any
other app gets a permission request with one option per choice, as Zed shows them. An untagged
question from a seat the work reached is named in the chat with where to answer it, because it could be another
chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it.
Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack, and no further
question opens after a cancel. A question that ends unanswered here (dismissed, cancelled, closed with the work, or
one the app couldn't show) is named in the chat as still open, as well as on its card. A question that wants an
answer in words is shown with where to answer it.
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
(`session/load`). Reopening replays each prompt, its work, the questions the team asked you during it with your
answers, and the reply, as they stand now. Nothing is asked again on reopening: a question still open comes up in
your next prompt's turn, and a new prompt continues the session.
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person: the only
registered person, or the one chosen with `rig gateway default <id>` when there are several (the address is
`<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
reason and the fix. The agent talks to the same daemon as `rig` on this computer: the local one, so start it first
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,10 @@ 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. |
| `advisor` | string | no | — | Default Claude Advisor model, with the same precedence. Passed as the session-scoped `--advisor` flag, independently of the primary model; never written to shared Claude settings. Blank or non-string values are ignored with an advisory. Other runtimes ignore it. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
@@ -180,8 +185,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 +203,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 +225,14 @@ 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]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
`openrig-core` does not get its hooks through this selection.
| `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 +276,32 @@ 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`, `plugins`,`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. Any other key selects nothing and gets a validation advisory, shown at
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
"not found in resource pool".
**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 project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
`<cwd>/.agents/skills`), then the `skills/` folder beside the RigSpec, then the home skill folder, then the managed
skills root. Pi looks only in the `skills/` folder beside the RigSpec. OMP does no filesystem discovery, so its
`uses.skills` references need declared resources. A discovered skill's ID is its `SKILL.md` frontmatter `name`, and a declared resource wins over a discovered skill with the
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 +318,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,7 +331,11 @@ 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
@@ -330,28 +368,28 @@ profiles:
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 requires a daemon version that supports it; keep bundles using it
pinned to such a version.
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. |
| `plugins` | `id`, `source` | Local plugin directories; selected with `uses.plugins`. |
| `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`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`.`source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. 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`
@@ -386,29 +424,88 @@ Where a fragment's table is one the user already declares, the user's table
wins: the managed table is dropped, their values are never merged, rewritten or
overwritten, and the rest of the fragment still applies.
**A fragment can't install a named Codex profile.** Codex versions with named
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
with top-level keys, and won't load a profile while `config.toml` still holds a
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
so neither form works there. Create the profile file yourself and select it with
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
Before a launch or restore, OpenRig checks that a selected profile loads, and for
a legacy table it names the file to move the settings into.
### 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
### Skill Resources
```yaml
skills:
- id: house-style
path: skills/house-style
on_restore: follow_source # optional — keep_saved by default
```
| Field | Type | Required | Default | Description |
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
spec as it is now, using the saved launch recipe and rig folder, and:
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
restore puts it back as usual;
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
- removes a skill that followed its source and that the profile no longer selects.
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
retain their byte-only ownership checks until a new projection or refresh records permissions.
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
already read into the retained conversation.
Following covers the whole selected skill folder, including scripts and references. It does not install external
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
not applied by this refresh.
### Guidance Resources
```yaml
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`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
---
@@ -428,7 +525,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 seat's own role (a file marked `orientation: role`, or at a `guidance/role.md` path) is `send_text`, so pod-mates never overwrite each other's role in the shared file. A seat launched before this rule keeps the hint saved at its launch, which restore (`rig up <rig>`), `rig seat launch --fresh` and handover replay, until the rig is instantiated from its spec again: `rig down <rig>`, then `rig up <spec file>`, which starts a new generation with fresh conversations. Otherwise a path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; a `.md` file is `guidance_merge`; and any other file is `send_text` |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
@@ -442,17 +539,27 @@ 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.
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
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
@@ -469,8 +576,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 +200,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 +240,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 the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | 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,14 +254,183 @@ 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.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
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
For Codex rebuilds, OpenRig also carries a complete supported native permission policy read from the live
predecessor's own conversation and process, without adding fresh-launch grants. A later recorded seat permission
or policy choice, or a differing declared successor profile, takes precedence. Missing, stale or unsupported observations keep the existing launch
behaviour and are reported as unverified in the handover result; requested modes and profiles alone don't prove
preservation. The result reports what was emitted, not native successor equivalence.
**Adopt it promptly.** The saved directory doesn't move the running process, but background work can use it before
adoption. Saved configuration isn't the live process's directory, and these examples aren't the only consumers. For a
running Claude seat:
- **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 without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
---
## What a seat is set to, and what it's running
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
running seat only through the action in the last column.
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|---|---|---|---|
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi` and `codex` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed on missing/unexecutable wrapper, or for Codex non-POSIX pane/missing session identity, retrying long Pi commands via host runner with wrapper flags preserved; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
Per harness: Claude Code's and Codex's permission choices are in the setup reference
| 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:
@@ -259,6 +518,9 @@ OpenRig performs best-effort deterministic runtime configuration for managed ses
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- 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
@@ -302,6 +564,58 @@ 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.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -327,12 +641,151 @@ remain shared.
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, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
For Pi tool-selection diagnosis and deliberate in-place recovery, see
[Pi tool-selection recovery](pi-tool-selection-recovery.md). It distinguishes
configured, currently intended, registered and active tools, including the TUI/RPC boundary.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
file-edit permissions allow the seat to write there.
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
```sh
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
```
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
attempt keeps its original path through the post-compaction restore request. The root gets an
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
atomic rename. Use a dedicated root: a conflicting
ignore file or an unwritable configured directory stops preparation with
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
The setting applies to new attempts without restarting the daemon.
The managed `/compact` command keeps its 600-character budget. When the selected map path or
the attempt's compaction instruction does not fit, the existing restore request carries it;
it never substitutes a newer preparation root or instruction. An explicitly selected root's
published map is also named in that request when the path fit in `/compact`. The default root
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
optional map completed at that attempt's path, or says that no map was required.
The compaction preparation and command-submission tests cover manual and automatic attempts,
long paths and instructions, settings changed during preparation, directory failures and
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
they do not prove a live Claude session's permission choices or compaction behavior.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
`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."
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
@@ -380,6 +833,54 @@ Existing user files and legacy role blocks are left intact; this is not a cleanu
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.
**The kernel's own folder.** A newly created default kernel works in
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
and from every folder above it, so a kernel working in the workspace root put its
guidance into every Claude project seat below it. If that folder can't be made,
the new kernel works in the workspace root, as before.
A kernel that already exists keeps its folders, usually `workspace.root`.
Upgrading moves no seat, changes no restored conversation and removes no file.
To move an existing kernel yourself, if you want to:
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
the folder ([details](#change-an-existing-seats-working-directory)).
2. Choose when each seat adopts it. A seat keeps its current folder until it
starts again, and the new start doesn't continue its current native
durable context into a new one. Nothing does this for you.
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
running rig's seat still works in that folder: a seat there still reads those
blocks, and `rig down` checks the same before it removes them. Remove only
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
line through its `END` line, as in the steps above, and keep everything else,
including your own text. Don't move or delete the whole file.
**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.
@@ -399,7 +900,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>`)
# Integration author contracts — DRAFT, first published with 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
## Release and support status
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
| Area | Status | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
## How the pieces compose
```mermaid
flowchart TD
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
R --> H["Built-in runtime or community preset"]
H --> N["Harness / runner: native conversation and control"]
H --> P["Author platform integration: placement and resources"]
P --> N
P --> F["Workspace, retained state and returned artifacts"]
R --> S["Optional application services"]
```
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
| Owner | Responsibility |
|---|---|
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
## Lifetime and recovery
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
## Questions contributors can help resolve
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
- What exact state and ownership must travel when compute is recreated or moved?
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
| 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."
Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-confirmed stop. A successful response has this shape:
```json
{"contract":"seat-stop.v1","expected":{"nodeId":"<node ID>","sessionId":"<latest session ID or null>"}}
```
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
# Install as $OPENRIG_HOME/community-harnesses/dsh.yaml, with openrig-dsh-runner.mjs (executable) on the seat's PATH.
format:1
name:dsh
command:openrig-dsh-runner.mjs
maintainer:Your name or team
runner:true
launch_result:true
first_input:true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
- noguidance, skills or startup files are placed; dsh reads its own AGENTS.md and skill folders
- when the pane closes, the runner cancels a running turn and stops dsh; anything dsh's tools started may keep running
returnfatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`,3);
}
returnfatal(`dsh couldn't reopen conversation ${resumeRef} (${method}: ${error.message}); the resume is unconfirmed and no new conversation was started`,4);
# Hermes's tools see its shared gateway's environment except for the names a profile passes through, so a seat whose
# config.yaml doesn't pass OPENRIG_SESSION_NAME would run `rig` as another seat, or as none (see config.yaml.example).
if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
# resume this launcher applies no OpenRig model.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
case "$OPENRIG_MODEL" in
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
# Install as $OPENRIG_HOME/community-harnesses/openclaw.yaml, with openrig-openclaw-launch on the seat's PATH.
format:1
name:openclaw
command:openrig-openclaw-launch
maintainer:Your name or team
limits:
- runs locally in the pane (openclaw tui --local), using only agent main, with no Gateway; Gateway-attached use isn't covered
- Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
- local resume may run a native database integrity check; check that the TUI has finished startup before sending work
- automatic session resets (session.reset) must stay off, or a key can start a new conversation
- typing /new in the pane moves the chat to a fresh session key; the seat still holds the name OpenRig gave it
- noguidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format:1
name:pi-tui
command:openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer:Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
A community harness is a coding harness an outside contributor maintains. You install it as one small data file, and a
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
it never reads an unsupported capability as idle, working, ready or verified.
| Capability | Supported | How it works, or what you see instead |
|---|---|---|
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
installed.
```yaml
format: 1
name: example-harness # must match the file name: lowercase letters, digits and hyphens
command: example-launch # one program and its arguments, typed into the seat's pane
maintainer: Example Maintainer
limits: # what the maintainer says doesn't work
- fork unsupported
launch_result: true # optional: the launcher reports the conversation it created (below)
# names_conversation: true # optional, instead of launch_result: OpenRig names the conversation (below)
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
starts nothing. OpenRig reports the conversation missing and starts nothing either: it never replaces it with a new one.
For a reported conversation, OpenRig also says so plainly when:
- **no usable record is written:** the resume isn't confirmed. The seat needs attention and keeps the conversation it
asked for; nothing is restarted;
- **the record names a different conversation:** the seat needs attention and keeps the conversation it asked for.
OpenRig doesn't switch it to the reported one;
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## Runner presets (`runner: true`)
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
without `launch_result` is refused.
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
field existed reads as plain.
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
unavailable.
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
Enter as one prompt.
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
- the seat, as `sessionName` and `nodeId`;
- `runtime`, the preset's name;
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
- an increasing `sequence`, and `occurredAt`;
- `hookEvent` and `subtype`, as in the table.
| The runner posts | When | The seat reads |
|---|---|---|
| `UserPromptSubmit` | it gave the agent a prompt | working |
| `active` or `PreToolUse` | the agent is working | working |
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
| `Stop` / the turn's stop reason | the turn ended | idle |
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
Agent's CLI.
- **The launcher:** it creates the session OpenRig named (`-c <name> --create-if-missing`), and on a resume reopens it
without `--create-if-missing`, with its own Hermes home per seat. A name that matches nothing ends with Hermes's own
"No session found" error.
- **Settings go in the seat's Hermes `config.yaml`, not in flags:** Hermes's CLI creates a named session with no launch
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
the other's running). It runs them with that gateway's
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
runs as whichever seat started the gateway, or as no seat; with it, each seat runs `rig` as itself. To check, ask the
seat's Hermes to run `rig whoami --json`. The launcher warns when a seat's `config.yaml` doesn't pass
`OPENRIG_SESSION_NAME`. This covers Hermes's CLI and TUI, whose clients pass their environment; its ACP clients pass
none.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
gateway has gone before relying on it. The preset says so in its limits.
Copy them and maintain them as your own.
## An example: OpenClaw
[`community-harness-examples/openclaw/`](community-harness-examples/openclaw/) has a preset and a small launcher for
OpenClaw's CLI.
- **Choose the mode:**`openclaw.yaml` runs an embedded agent with `tui --local`. `openclaw-gateway.yaml` runs a TUI client of an existing Gateway. Configure that client's Gateway route and authentication through `OPENCLAW_CONFIG_PATH`; the launcher never starts or stops the Gateway.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
## An example: Cursor
[`community-harness-examples/cursor/`](community-harness-examples/cursor/) has a preset and a small launcher for Cursor's
CLI.
- **The launcher:** it reports the chat `cursor-agent create-chat` makes, then reopens it. Each seat gets its own Cursor
config folder unless `CURSOR_CONFIG_DIR` is already set. Cursor keeps its chats and `cli-config.json` there, and its
login elsewhere.
- **The preset:** it declares `launch_result` and `resume`. Cursor's `--resume` with an id it doesn't hold starts a new,
empty chat without saying so, so on a resume the launcher first checks that the chat's store exists. When it doesn't, the
launcher reports `error: "missing"` and starts nothing.
- **First input at launch:** the preset declares `first_input: true` and `startup_assisted: false`. The launcher passes
the generated startup text as Cursor's positional first prompt. This route and same-chat resume were checked with
Cursor Agent 2026.10.22-27dcf5f. Later tool approvals use Cursor's native prompts; startup submission does not answer them.
- **Sender names come from the sending session.** A command run from an unattached operator shell can arrive as
`<unknown sender>`; that doesn't mean the receiving Cursor seat lost its identity.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
## An example runner: dsh
[`community-harness-examples/dsh/`](community-harness-examples/dsh/) has a runner preset (`runner: true`) and a small runner
for DeepSeek Harness, which speaks ACP on stdio and has no terminal UI of its own. It's written for dsh 0.2.1-rc.2 and
checked only against a stand-in agent.
- **The runner:**
- it opens the conversation and writes the launch record;
- it turns each submitted block into one prompt;
- it reports turn boundaries as activity hooks;
- it cancels on `/abort` or Ctrl-C, as the [runner contract](harness-runner-contract.md) describes.
- **Permissions:** it sends each request to OpenRig, which asks the default person or the seat's override.
- **When no one can be asked, or OpenRig can't answer,** it permits with dsh's own allow option (`allow_once`, else
`allow_always`), which is OpenRig's default. `allow_always` creates a standing grant in dsh.
- **It refuses and holds needs input** only after `--deny`. A person's no when dsh offered no reject option, or a
request OpenRig says isn't the current occupant's, is answered cancelled.
- **Each seat gets its own dsh home,** with dsh's session-log and plugin-list uploads to DeepSeek turned off. Give that
home credentials, or put a provider key in the seat's environment.
- **Resume isn't declared.** The runner reopens a conversation by the method dsh advertises, and reports one missing only
on dsh's own not-found error. Declare `resume: true` only after a real dsh has reopened a conversation and refused a
missing one.
Its [README](community-harness-examples/dsh/README.md) has the setup and a check you can run without a model. Copy both
and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
The contributor-owned launcher reads the file and uses its harness’s supported initial-prompt route: the Cursor example passes one positional prompt; OpenClaw uses `--message`. Core knows neither flag. If no initial identity turn is due (an ordinary resume, for example), the variable is absent and the launcher starts without a first message. Later startup files and actions use their existing delivery paths; they are not folded into the launch file.
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Pass the seat's declared model at launch
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
model flag or config key.
OpenRig keeps three things apart:
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
refuses is the harness's own error.
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
- **Cursor:**`--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
out `--model` doesn't show that it keeps its earlier model.
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
[Browser access](browser-access.md).
## Event streams
All of them are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
rows stored by older versions still replay until an events retention (a separate change) removes them.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/views/sse`** (alias `/api/views/watch`) and **`GET /api/views/<viewName>/sse`**: `view.changed`, live
only. Its `id:` is the `seq` of the stored event that caused it, so it can repeat across views and is not a replay
cursor.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
- **A rig's queue history:**`GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:**`GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:**`GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.**`GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
| 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`; `npm run test:ui` is the separate UI
suite. Both sets are readable in the root `package.json` scripts.
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
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.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
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` or fallback `escalates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -120,7 +157,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
# Execution and platform integration contract — draft
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
| Identity or handle | Meaning and responsibility |
|---|---|
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
## A minimal composition using existing surfaces
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
```yaml
pods:
- id: dev
label: Development
members:
- id: coordinator
agent_ref: local:agents/coordinator
profile: default
runtime: claude-code
- id: worker
agent_ref: local:agents/worker
profile: default
runtime: example-contained-claude
```
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
```yaml
format: 1
name: example-contained-claude
maintainer: Integration author
command: ./scripts/launch-contained-worker
launch_result: true
resume: true
startup_assisted: true
limits:
- Requires an explicitly prepared environment binding and guest projection.
- Native resume is qualified only for the documented retained-state recipe.
- Viewer closure does not implement turn cancellation or resource deletion.
```
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
## Placement and launch receipts
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
- canonical seat, harness version and native resume-reference type;
- provider/host/workspace, immutable environment ID and execution generation;
- image/policy versions, requested resources and observed readiness;
- inner working directory, private native-state location and projection manifest;
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
- lifecycle operations supported, completion observations, pending effects and errors.
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
## Project files and credential references deliberately
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
## Own only the effects assigned to the integration
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
Describe these effects independently:
| Operation | Required distinction |
|---|---|
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
## Resume exactly, or return an honest disposition
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
## Export results before teardown
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
## Proposed generic platform seam — not implemented
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
# Gateway participants: several people, shared channels, organizations and policy
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
A person can also bind to a local bridge adapter using a `relay` binding:
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
### Inbound Custody & Resolution
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token.
- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform.
- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:<fingerprint>`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:<fingerprint>`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`).
### Exclusions & Scope
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
---
## 2. Secrets & Environment Configuration
Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:<absolute-path>`).
### Security & File Requirements
- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected.
- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`.
- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file.
- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional.
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
- `recipient.handle`: Recipient's registered safe alias for this connector.
- `intent`: `"decision"` (actionable question) or `"update"` (informational notice).
- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`).
- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`).
- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint.
- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`.
- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.
- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question.
#### Inbound Statuses & Responses
- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`.
- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored.
- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`).
- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored).
- **404 Not Found**: Queue item referenced in `replyRef` does not exist.
- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`.
- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes).
- **503 Service Unavailable**:
- `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`).
- `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects.
- `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `<openrig-home>/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.)
- `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen.
---
## 4. Identity & Binding Configuration
Bindings are managed through human fragment files (`gateway/humans/<entityId>.yaml`) and CLI verbs (`rig gateway human`).
- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch).
- `handle`: Optional platform alias (`handle=<alias>`). Required for inbound reply resolution; omitted means outbound-only.
Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle.
---
## 5. Operational Lifecycle
### Step 1: Provision Secrets File
Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`):
# Replace an existing binding slot (requires the full binding spec)
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
```
> [!NOTE]
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
```bash
rig gateway default alice
```
### Step 3: Activate Daemon
When bindings or secrets files change, restart the daemon process:
```bash
rig daemon stop
rig daemon start
```
*(OpenRig does not provide a `rig daemon restart` command).*
### Step 4: Verify Readiness & Status
```bash
# Check delivery readiness for all participants
rig gateway human list
# Inspect detailed readiness status for an individual
rig gateway human show alice
# Check daemon process status
rig daemon status
```
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
### Step 5: Removing Bindings or Participants
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
```bash
rig gateway human remove alice
```
*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).*
---
## 6. Troubleshooting Common Conditions
| Condition | Observation | Resolution |
| :--- | :--- | :--- |
| **Missing Secrets File** | `deliveryReadiness.state: not-ready`<br>`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. |
| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 <secrets-file>` or `chmod 400 <secrets-file>`). |
| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`<br>`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. |
| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`<br>`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. |
| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. |
| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). |
| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `<openrig-home>/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. |
| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. |
When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`<openrig-home>/state/relay-inbound-seen.jsonl`):
1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection.
2. **Preserve a full backup**: Copy `<seenPath>` to a timestamped backup outside `state/` before making any edit.
3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:<fingerprint>` or `processed:<fingerprint>`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay:<eventKey>:<fingerprint>"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible.
4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (`<eventKey>`) and `status` (`received:<fingerprint>` or `processed:<fingerprint>`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup.
5. **Resume and retry**: After a verified, history-preserving repair is saved at `<seenPath>`, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`.
For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`.
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
terms in 0.7.6. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
formats or loaders that exist.
## 2. Declare it: what the preset says, fixed at launch
```yaml
# $OPENRIG_HOME/community-harnesses/dsh.yaml
format: 1
name: dsh
command: dsh-seat # your runner; it starts the agent itself
maintainer: <you>
runner: true # since 0.7.6
launch_result: true # required with runner
resume: true # only after you've checked a real reopen and a refused missing one
limits:
- permission requests go to a person through OpenRig (section 7), and are permitted when no one can be asked
- a prompt whose whole text is /abort can't be sent
- fork unsupported
```
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
moment. Editing the preset changes the next launch, not a running seat.
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
- **The other fields keep their existing meanings:**
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
## 3. Identity: report the conversation once, in the launch record
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
`session/resume`).
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
| When | `hookEvent` / `subtype` | The seat reads |
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 ×`livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
reads a runner seat idle, so with nothing else the seat reads unknown.
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
outstanding and the agent process you started is alive. It isn't progress.
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
`livenessSeconds` ÷ 2.
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
exit, and post that boundary first.
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
- **A beat counts only when all of these hold:**
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
`accepted` post that opened its turn;
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
exit;
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
included;
- the seat has no attention outstanding, such as a permission request waiting on a person.
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
runs ahead. Give every post its `occurredAt`.
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
Beats are ignored until attention clears.
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
a turn, never clears attention, and never stands in for a `Stop`.
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
- **A runner that sends no beats** behaves as before.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
tied to a particular `rig send`.
- **The generation attributes a post; it isn't authentication.**
- A post carrying an earlier occupant's generation never counts for the live seat.
- The hook token is one local token, so any process holding it can post for any seat.
- **A seat activity grant** is what a runner in another environment, such as a sandbox guest, receives instead of the
local token. It arrives in the same `OPENRIG_ACTIVITY_HOOK_TOKEN`, with an `OPENRIG_URL` the guest can reach, and it
speaks only for its own seat and launch generation:
- with a grant, the generation is authenticated, and you may leave out `nodeId` and `generation`;
- a post for another seat is refused with `403 activity_grant_wrong_seat`;
- a post claiming another generation, or from an occupant that has since been replaced, is refused with
`409 activity_grant_stale`;
- a post before the launch has registered its occupant is refused with `409 activity_grant_pending`;
- a post with a revoked grant is refused with `401 activity_grant_revoked`, including a post whose grant was revoked
while its body was arriving.
A refused post records nothing, so the seat's activity stays as it was, or unknown.
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
check it.
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
main port or the daemon refuses to start.
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
## 5. Input: one submitted message is one prompt
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:**`rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
- **Never split a block,** and never act on lines inside it.
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
contain.
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
- **Limits to keep and to list under `limits`:**
- a prompt whose whole text is `/abort` can't be sent as a prompt;
- a Ctrl-C byte inside a paste also aborts;
- without a TTY, input is read line by line;
- this isn't a binary-safe channel.
## 6. Cancel: requested isn't the same as done
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
envelope, so it's an ordinary prompt.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
## 7. Permissions: a person answers, and the default is permit
### The first version: refuse every request (history; still what `--deny` does)
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
`{"outcome": {"outcome": "cancelled"}}`).
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
turn's `Stop` or idle beats while holding it.
- **It clears** when your next prompt is accepted, or when an operator clears it
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.8,
replaces this as the default.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
### The answered path (0.7.8)
**Only requests the agent actually makes reach anyone.** The harness's own permission mode decides that, and a harness
that runs without asking gains no approval step. Ordinary conversation and `rig send` are unchanged.
**What happens to a request, in the person's terms:**
| Situation | What happens |
|---|---|
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, and OpenRig records the permit (below). That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
**What the ask shows:**
- the seat and the conversation;
- the tool call, as the agent described it;
- why this person was asked;
- every option the agent offered, each with its native meaning. `allow_always` means the agent's harness remembers
the choice and allows similar requests without asking. OpenRig doesn't record or revoke that.
**How a person answers, and what the agent gets:**
| The person… | The agent gets |
|---|---|
| clicks an option: the asked person, any other registered person, or the primary user in the TUI | that option |
| replies with just a yes ("yes", "ok", "allow", "go ahead" …) | the agent's allow (`allow_once`, else `allow_always`) |
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
`stale`, and one withdrawn at the turn's end reads `withdrawn`. Neither can take a later answer.
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
agent as a prompt.
- **From 0.7.9, each permit OpenRig decides by default is recorded,** once, as a stream item from the seat. That covers
no one to ask, and an ask closed without a choice (the row passed to an agent seat included). The record names:
- the tool call;
- why no person answered, including, for a closed ask, who closed which row and how;
- the option the agent got, with its kind. An `allow_always` option is marked as remembered by the agent's harness;
- the conversation and request, or the ask.
For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it.
Read the records with `rig stream list --tag runner-permission-permit`, or `--source <seat>`. A record wakes no one,
and one that can't be written doesn't change the permit.
**The runner's protocol:**
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
- A relaunch makes the earlier occupant's open requests stale on its own.
**Refusals, at submit or while reading.** One rule covers both. Cancel only a request that certainly isn't the current
occupant's live one, and hold no attention. Anything that may still be a live request permits, as when OpenRig can't
answer. Only the seat's opt-in refuses and holds attention.
| Code | Status | Meaning | The runner |
|---|---|---|---|
| `denied_by_seat` | 409 | The seat's operator opted in to refusal (`--deny`). | refuses and holds attention |
| `options_unsupported` | 422 | No one can be asked, and the agent offered no allow option to permit. | refuses and holds attention |
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
permission nobody chose. Such a permit is the runner's own, so OpenRig has no record of it: the runner prints it in
its pane. That covers:
- an OpenRig without this path, which answers 404;
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
times first);
- a refusal the table marks "permits", and any status or code this section doesn't list.
**Limits of this path:**
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
needs input, until the agent's turn ends.
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
change.
- **A person's answer is recorded on the ask itself,** and a default permit as the stream item above. That item lasts as
long as stream retention keeps it. A permit the runner decides on its own is in its pane only.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
`sessionCapabilities.resume`, which dsh `0.2.1-rc.2` uses), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
- **Telling "missing" from "won't reopen":** an agent may answer one error for both. dsh `0.2.1-rc.2` answers `-32602`
for a conversation it doesn't have and for one already active elsewhere. So don't read an error's code or text as
missing unless the agent defines it that way, as ACP's not-found `-32002` is.
- **Report missing only on the agent's own not-found,** and leave anything else unconfirmed.
- **When the agent advertises `sessionCapabilities.list`,** its `session/list` can show that a conversation exists:
one it lists but won't reopen is unconfirmed, even on a not-found. Follow `nextCursor` (an opaque string, possibly
empty), and treat a page you can't read as deciding nothing.
- **A list doesn't show that a conversation is gone,** unless your agent guarantees it lists every conversation. An
agent may leave out one that another running process holds unsaved; dsh `0.2.1-rc.2` can. So still try the reopen
for a conversation the list doesn't name.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
| The same conversation reopened | the same `nativeRef` | resumed |
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
- **Never start a new conversation on a resume.**
- A conversation saved under another harness's runtime is never handed to your launcher.
## 9. Context, model and shutdown: whose job is what
| Responsibility | OpenRig does | Your preset or runner does |
|---|---|---|
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
## 10. What counts as evidence
- **Keep three things separate:**
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
that `--verify` recognises it.
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
marked done, or an artifact written.
- **Your own tests** should run against a fake agent that covers each section above. The
[runner conformance check](runner-conformance/README.md) does that for you: point it at your runner, and it reports
which promises your runner keeps.
- **The useful real check** for a maintainer:
- a seat starts and reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled;
- a resume reopens the same conversation, and a missing one is refused, never replaced.
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
compatibility evidence for the versions you name, not certification.
## Contributor checklist
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
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
@@ -67,6 +112,20 @@ 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.
Codex's initial sign-in chooser and API-key entry are recognised as `login_required`, so startup reports the
sign-in step instead of waiting for a generic readiness timeout. Finish that step in the seat's terminal, then use
the continuation command above. A quoted or dismissed login screen is not a current login gate; other layouts
keep their existing readiness handling. Claude's persistent "Not logged in" footer alone is not a login refusal, because a
configured gateway may authenticate its requests.
### You can't tell which instance or configuration is involved
Read [instance layout](instance-layout.md) (`rig context get reference/instance-layout.md`) and
@@ -80,15 +139,19 @@ 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),
# Hosted members: a tool outside the terminal on your team
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
## Set one up
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
[Rosters](rosters.md)).
3. **Issue its grant:**
```sh
rig mcp token add grok --seat grok@research --rig research
```
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
that rig's seats and work.
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
MCP clients aren't browsers.
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
## How it works its rows
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
- **The member works it** through the MCP tools:
- `rig_queue_list` with `owned` lists its rows;
- `rig_queue_claim` takes one;
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
- **It can send work too:**`rig_queue_create` to its rig's seats.
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
## Asking a person while keeping the work
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
row to the member in progress.
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
question closes, the member's row returns to pending.
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
unlimited delivery.
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
member, so it checks its rows again.
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
## Ring it when work lands
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
entry. The entry holds secret names, never values:
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Assistants connected through a grant service
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
| `question`/`read`, `question`/`answer` | `rig_question_list`, `rig_question_answer`, only when the grant names its person (below) |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
is exactly that seat.
- **What it reaches:**
- It hands work to its rigs' other seats, never to a person or another host's seat.
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
- **Answered:** closed `done`, with the answer in `resolution`.
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
### A person answering the team's questions through their assistant
A grant can name the person who owns the connection: `person.entity_id`, their gateway entity id on this instance, so their
address is `<entity_id>@external`. The service sends it only for a connection that person owns. With it and the `question`
pair, the person's assistant can read the team's questions waiting on them and submit their answers, without opening
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
unusable `person` withholds only these two tools while the grant's other tools keep working.
- **Register the person** on the instance, once: `rig gateway human add <entity_id> --display-name "<name>" --default`.
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
registered person (such as `human@host`) are theirs too.
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
them. Each comes with its options when it has any, and the `revision` an answer names. A question that moved to someone
else between Steering's view and the read of its row is left out.
- **`rig_question_answer`** takes the question, the revision the person saw, and either the options they chose (one per
question) or exactly what they typed. It records through Steering's own write path (`answer` for an ask, `resolve` for a
park), as the person, so Steering shows the same answer and the asker's work continues once. The answer's audit names the
route it came by: the caller's seat, the grant's principal and the grant's id and version.
- **What it says back:** a success names what was submitted (`submitted`), never a fresh read of the question's answers.
- `answered`;
- `recorded`, when parts of the question are still unanswered;
- `already-recorded`, for an identical retry whose submission is already on the question, recognised by its operation id
(never by an equal answer). That includes a settled ask or a park that's back at work;
- `question_changed`, with the current question, when someone else changed it since the revision the person saw;
- a retry of a submission that stopped partway confirms the parts already recorded and stops there (`question_changed`,
with `recorded` and `notRecorded`). The rest goes in a new answer to the question as `rig_question_list` shows it now,
so a stale retry never overwrites an answer someone gave in the meantime, including one recorded with no action (a
Slack click);
- a refusal partway names which parts were recorded (`recorded`, `notRecorded`). A send that got no answer, or a server
error, leaves that part `indeterminate`: send the same call again to confirm what was recorded;
- `not_waiting_on_person`, when it was answered or closed, or isn't theirs;
- `person_not_registered`, when the grant's person isn't registered on this instance. While no one is registered, Steering
shows every request, so nothing is answered for anyone.
- **What it never does:** answer another person's question, reach a team-internal row, or record a decision of its own. A
delegated decision (`rig_queue_decide`) isn't offered to a grant-service caller.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one, except through a grant service's grant that names its person, which records that person's own answers (above).
--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.
For a Claude login supplied by `CLAUDE_CODE_OAUTH_TOKEN`, an explicit one-credential read is available:
```sh
rig provider status --refresh --provider claude --claude-token-file /absolute/private/personal.token --credential-label Personal
```
The file must be readable by the daemon, regular, at most 8 KiB, and contain only the token. Ownership by the daemon user and mode 0600 or
0400 are recommended; different ownership, broader permissions and symlinks produce warnings, not refusals. Provision it through your existing credential
custody; never put token values in arguments, seat environment settings, evidence or a queue item. The path is on the selected daemon's
machine. Repeat the command for another labelled file when you want to compare subscriptions. This option does not fan out with
`--all-hosts` or `--hosts`.
**Each token refresh spends one model turn.** Status and TUI reads show the cached result and never probe. A timeout or refusal does not
prove no allowance was spent. The probe uses a temporary home/config/work directory and a single Haiku turn, with CLI flags requesting no tools, hooks or MCP. Unsupported CLI flags or missing events are reported as failures/unknown, not as free capacity. The event format is evidenced on
Claude Code 2.1.310; other versions may report different fields. These are credential labels you supplied, not provider-verified account
names, and do not prove a running seat uses that credential. The ordinary status-line sample/account join remains unproven.
Only actually reported window percentages and resets are measurements. A rejected window can have an unknown percentage; overage rejection
is separate from subscription rejection. Readings and their times survive daemon restart, marked historical until refreshed. Files with
different references/labels stay separate; equal resets or matching labels on another host never establish one allowance or extra room.
**Never put `--refresh` in the timer's check.** `rig provider status --all-hosts --refresh` starts a native read of the
accounts on every machine. Run it only when the person asks for a fresh reading now.
**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.
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, and `member.add`. A client should check this marker before relying on correlation; an older daemon may ignore an unfamiliar request header.
For `POST /api/rigs`, `POST /api/rigs/import`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
The operation contains the rig and node IDs committed by that request. Its `current` field reads the database at response time, including whether recorded resources still exist and the latest recorded session for each node. It does not inspect tmux or prove a launch finished. A reservation with no IDs can mean that execution is still underway or that it failed before creating resources. Replaying an exact request that failed before creating resources returns HTTP 200 with `operation.rigId: null` and `operation.nodeIds: []`; inspect those fields to distinguish it from a replay that recorded resources. Replays never execute the mutation again, retry a launch, or recreate deleted resources. Use a new UUID only when deliberately starting a new execution.
This contract stores neither the original response nor a recovery verdict. An HTTP response can be lost after a database commit. External launch effects are outside the database transaction, so the contract is correlation rather than exactly-once execution of external effects.
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. Setup also tries to record the selected providers
for a new kernel's first boot; its summary warns if it could not save the choice.
### 3. Start OpenRig
```sh
rig daemon start
rig status
rig ps --nodes --rig kernel
```
On the kernel's first boot, the daemon uses the providers selected by `rig setup --providers` that are signed in.
If no provider choice was recorded, it considers every signed-in provider. An existing kernel keeps its seats;
changing the setup choice does not replace them. An `OPENRIG_PROVIDER_SELECTED` value in the daemon's environment
takes precedence over the saved choice.
**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. `rig down` takes the hooks and
status line back out once no Claude seat uses that directory.
- **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.
## Learn it in this order
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
3. **One command:**`rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
lists every command.
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
your version.
## More
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
opening. See the [setup reference](getting-started.md).
- **Stuck?** Run `rig context get help`, or read [Help](help.md).
@@ -14,7 +14,39 @@ it is the mechanism that makes shipped chain files (see
|---|---|---|---|
| UserPromptSubmit | ✓ | ✓ | deliver due or on-demand context at a model-visible boundary |
| Stop | ✓ | — | catch a long Claude turn crossing the growth threshold |
| PostCompact | ✓ | ✓ | retain exact compaction due-state; deliver on the next prompt |
| PostCompact | ✓ | ✓ | retain exact compaction due-state; deliver on the next actionable prompt |
**How the hooks reach a seat.** A Claude seat whose resolved plugin selection includes the shipped `openrig-core` plugin
(for example `plugins: [shared:openrig-core]`, or the same plugin under another name) starts with `CLAUDE_CODE_PLUGIN_DIRS`
set to the vendored plugin (`$OPENRIG_HOME/plugins/openrig-core`), so Claude loads `hooks/claude.json` with no setup step, on a
fresh launch, a restore, a handover successor and a launch with a permission mode. A seat that didn't select the plugin is
launched as before and doesn't load these hooks. The folder a spec's plugin selection projects into the seat's workspace is
not what Claude loads. A list the seat declares itself in `CLAUDE_CODE_PLUGIN_DIRS` keeps working, with the vendored plugin
added after it.
Claude Code reads this variable from v2.1.280. OpenRig doesn't check the Claude version at launch: an older Claude ignores the
variable, starts normally, and doesn't load these hooks. The behaviour was checked on Claude Code 2.1.296 and not on
earlier versions.
A Claude started from inside a seat (its shell or Bash tool) inherits the seat's environment and so loads the plugin as well.
A seat that loads the plugin is also given `OPENRIG_SEAT_PROJECT_DIR`, its launch project, and the plugin's seat effects (the
activity and session-identity posts, the compaction hooks, refocus, and the first-launch marker) run only when that Claude's
`CLAUDE_PROJECT_DIR` names the same folder (compared by real path, so a trailing slash or symlink doesn't matter). A nested
Claude in another project, or in a subfolder of the seat's project, therefore changes nothing for the seat, and a `cd` inside
the seat's own session keeps working because `CLAUDE_PROJECT_DIR` is fixed when Claude starts. A nested Claude in the seat's
own project folder is the same project and still acts as the seat, as before this change. Without either variable (a seat
launched before this change, a manual run) the hooks do what they always did. The plugin's activity relay stays quiet next to
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
OpenRig records its approved bundle in `CODEX_HOME/.openrig-core-hook-trust.json` and leaves Codex's immutable `.hook-trust` baseline untouched.
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
`OPENRIG_NODE`.
Claude's additional firing signal is **transcript growth** — not turns (one turn
can burn 200k tokens across fifty tool calls) and not wall-clock (the failure is
@@ -25,11 +57,38 @@ redundant double-fire around Codex's own compaction cadence. Set
`OPENRIG_REFOCUS_NOW=1` for an on-demand refocus and
`OPENRIG_REFOCUS_ENABLED=0` to disable the feature. Fresh `SessionStart` is
always a no-op: the default onboarding pack owns fresh orientation. Both
runtimes retain `PostCompact` due-state for the next prompt. Otherwise the hook
is a silent no-op, and it degrades to silence on unrelated hook errors. A
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:
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
- Maximum gzip compression (level 9)
This means the same inputs always produce the same archive hash.
Identical staged bytes and file modes (including identical manifest timestamps and provenance) produce the same archive hash. A mode change can change the archive hash even when the file contents are unchanged. Use the package digest to compare packaged file content independently of manifest metadata and file modes.
---
@@ -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.
Loaded 100 of 2680 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.