Compare commits

..
549 Commits
Author SHA1 Message Date
Mike Schwarz 0a91a8f0f1 fix(daemon): let preparation hooks age before managed compact (#2764)
The managed compaction's guarded final send waited 1 s plus the 8 s readable budget, while a fresh activity hook from its own preparation keeps the seat reading as running for 15 s, so an exhausted seat whose pane was idle could time out before /compact was ever sent. The default idle wait now uses the transport's existing readiness freshness window (SEND_READINESS_FRESHNESS_MS), still clipped to the original attempt deadline, and the guarded replacement preparation send uses the same default. A genuinely running seat, a permission prompt or typed input still holds the send, and cancellation, generation and unknown-write handling are unchanged. Cost: a busy seat's guarded send can wait up to about 14 s longer.
2026-11-08 10:10:43 -08:00
billbenetti 1760d6e776 fix(daemon): make rig workflow validate read-only (#2726) (#2763)
rig workflow validate parses and validates a spec in memory (the same parseWorkflowSpec and WorkflowValidator checks as before) and no longer writes to workflow_specs, so a new id@version stays unregistered and a draft with a registered id@version from another path no longer replaces it (#2726). Registration stays with instantiate, run, the workflows-folder scan and unregister; a draft validated only outside the workflows folder is reachable by its path, not its bare name. No new error code. Contributed by billbenetti.
2026-11-08 09:58:48 -08:00
NIKHIL b40bc71ca6 fix(daemon): keep spaced settings JSON whole when reading a Claude command line (#2665)
The Claude command-line reader keeps a --settings or --mcp-config JSON value whole when it's written with spaces, by tracking brace depth on those two options only, so a seat launched through a proxy alias with spaced settings can be verified and its conversation read (#2508). Other options' values aren't grouped. Contributed by Nikhi00718, with billbenetti's diagnosis.
2026-11-08 09:29:26 -08:00
Mike Schwarz 4d7c50d514 fix(openshell): capture optional workspace volumes from mounts (#2755)
OpenShell 0.1.6 custom-image sandboxes mount control and channel volumes without a managed workspace volume, so delete-owned refused them before dispatch, which also blocked recreate and exact restore. Capture now keeps the exact OpenShell-owned volumes observed on the selected workload and supervisor mounts: control and channel stay required, the workspace volume is kept when present, and unrelated or user-supplied mounts are excluded. Complete post-delete inventories, container and socket identity checks and environment_cleanup_unconfirmed are unchanged. A native run on OpenShell 0.1.6 passed cleanup, recreate, exact resume and byte preservation; startup replay failed and strict before-tools recall was not met, so this is a bounded cleanup repair, not an end-to-end worker pass.
2026-11-08 09:24:38 -08:00
Mike Schwarz 1538231b1f fix(terminal): let tiles show whole lines, and attach only while someone types (#2757)
A tile now shows its seat by reading the screen and letting the tile wrap it, so a narrow tile shows whole lines and never sizes the seat. A writable tile attaches on the first key: the terminal stays in one raw mode from that key to the attach, the first keys go through the new client (control bytes by name), a paste is kept framed and whole, and every read and the first input check the server and seat identity, so a restarted server's reused session can't receive the keys. The tile detaches after 30 s idle. Read-only tiles only show. Limits: no real Herdr 0.9.5 pane was run; redraw flicker and Herdr's agent detection on a showing tile are unchecked; keys in the same burst as a paste skip tmux's key table; tmux prefix plus PgUp in one burst reaches the agent as text.
2026-11-08 09:15:59 -08:00
NIKHIL 44affadaa6 fix(queue): hold a prompt-clear wake retry while a compaction owes its restore (#2666)
retryPromptWakesOnClear no longer treats a finished managed compaction's "Compacted" as a cleared prompt while the seat still owes a post-compaction step (turn boundary, restore or audit). It uses the same hasPendingPostCompactStage check that parked-owner reminders already use, and the retry stays armed so the next clear after those steps sends it. This is the Recovered queue notification part of #2575, where the resent wake went in 0.1 s after "Compacted" and the agent worked for 21 minutes on its compacted context without a restore. Contributed by Nikhi00718, with billbenetti's timing from the issue.
2026-11-08 09:09:54 -08:00
billbenetti 011b995835 fix(daemon): start the seats' tmux server in a systemd user scope of its own (#2710) (#2749)
When a systemd user manager is available, the daemon starts the seats' tmux server with systemd-run --user --scope, so seat processes run in their own transient scope instead of the daemon's cgroup (#2710). --expand-environment=no keeps the server's argv literal on systemd 258, where scope expansion is on by default. An older systemd that refuses that option or OOMPolicy=continue gets a retry without only the refused option, recognised from systemd's own message on stderr; a timeout or any other failure falls back to the existing start with its reason. The reason a scope isn't tried at all is logged once per adapter. Contributed by billbenetti.
2026-11-08 08:55:40 -08:00
Mike Schwarz ce0186c408 docs(roadmap): name 0.7.10 as the current release (#2760)
ROADMAP.md names 0.7.10 as the current release and links its release notes. The summary is the published notes' first two sentences. No roadmap item is removed, since the published notes don't say any of them shipped. Text only.
2026-11-08 08:52:14 -08:00
Mike Schwarz 545a423489 chore(release): bind the packaged-plugin baseline to published 0.7.10 and advance openrig-core to 0.1.27 (#2756)
scripts/fixtures/packaged-plugins-last-release.json is bound to published 0.7.10 (archive sha256 c55aff7e, openrig-core 0.1.26 payload 23ed778d), taken with the check's own --snapshot recipe, and both openrig-core manifests advance to 0.1.27. Six openrig-core files changed since v0.7.10, and the installer keeps installed bytes whose version is equal or newer, so without the bump an upgraded install would keep 0.7.10's copies. The check fails at 0.1.26 and passes at 0.1.27. Same chore as earlier releases' baseline updates.
2026-11-08 06:50:27 -08:00
Mike Schwarz d2c5bbdbd2 docs(changelog): carry the published 0.7.10 section onto main (#2754)
Carries the published 0.7.10 section from the v0.7.10 tag (a226b1b6) onto main above 0.7.9, as the 0.7.9 section was carried. Eight lines change from the tag's text so the OpenShell statements match the tested result and the published release notes: the guest CA fix holds with OpenShell 0.1.6 (sign-in, useful work, a verified export and a finished task), the maintained cleanup still fails against 0.1.6 because it doesn't mount a workspace volume, and recreate and exact restore haven't run. Everything else is the tag's section byte for byte. Text only.
2026-11-08 06:31:00 -08:00
Mike Schwarz 220e27a9a7 fix(daemon): one standing notice for a seat's unclaimed rows instead of a finding per row back to it (#2752)
In runStuckSweep, an unclaimed-obligation finding whose route would be the row's own destination, for an agent seat with no declared route, joins one standing notice per seat (kind unclaimed-backlog) instead of a finding per row. The notice records every row it stands for, is rewritten in place without a wake, and closes only when each of its rows is claimed, parked or closed, or has its own finding actually written; a raised threshold or the wake ladder is not resolution. A closed notice holds back only the rows it listed, until each has newer evidence, and an unreadable list counts as unknown. Seats with a delegation parent or escalates_to route, failed routes, people, terminal seats and the other finding kinds are unchanged. No setting, route, table, migration or error code. Reported with data by billbenetti.
2026-11-08 05:45:18 -08:00
Mike Schwarz 859b540cf0 test(daemon): keep the refocus work-root hook cells off the 2 s lookup cap (#2753)
refocus-work-root.test.ts's hook cells set OPENRIG_REFOCUS_BUDGET_MS=52000, the documented maximum, with a 60 s test timeout, so the hook's python3 trace no longer hits its 2 s default lookup cap on a slow runner. A new cell puts a python3 that sleeps 3 s on the hook's PATH; without the budget it fails with the same TRACE GAP ETIMEDOUT text CI showed. No product file changes.
2026-11-08 05:13:45 -08:00
Mike Schwarz 0fce9b52e7 fix(daemon): prove a Claude a launcher execs through the native installer's symlink (#2751)
In native-process-lineage.ts, the script-launch rule also accepts the OS name claude when the process's path is a recognized Claude executable, which covers an exec'ing launcher in front of the native installer's symlink to a versioned file. Both Claude argv readers now treat --chrome and --no-chrome as switches without a value, so a launch carrying them still proves its conversation. The foreground, single-candidate, token and fingerprint checks are unchanged. Tests cover the matcher, delivery, three refusals, an adapter resume reaching ready, and a wrong conversation refused. Reported with the full launcher chain by oneandonlydean.
2026-11-08 05:05:36 -08:00
Mike Schwarz 4d6200e11a fix(mcp): an external caller's task reaches a seat named without its rig (#2750)
In rig_queue_create, for an external grant only, a destination without @ resolves to the configured receiver when the name is that seat's and this connection's grant reaches it, or otherwise to that seat in the connection's one rig; the result passes the same scope check and must be an existing seat, or nothing is written. A grant over several rigs never picks a rig for a name alone except the receiver's. An out_of_scope refusal adds "Omit destination to reach the team's configured receiver" only when this connection's grant reaches the receiver, without naming it. Local callers (stdio, or HTTP without a grant service) are unchanged.
2026-11-08 04:36:31 -08:00
Samuel 3ac7aea4ee feat: add local Claude Code launch wrappers (#2746)
Extends #2609's launch-wrapper contract to local Claude Code seats. The wrapper is inserted where each Claude path already builds its native command: the classic shell shim, the pinned classic exec in the adapter and resume adapter, and the managed launch script's final exec after its environment is rebuilt. A declared wrapper that is missing, unresolvable or can't exec its target stops the launch with the existing reason rather than starting the seat unwrapped. With no wrapper declared, every Claude command and path is unchanged. No new stored field, migration, mode or error code. Docs: agent-startup-guide.md and rig-spec.md.
2026-11-08 03:19:57 -08:00
Mike Schwarz d7d1dd41eb fix(daemon): keep a helper harness's turns off its seat's activity (#2743)
The activity relay now sends each activity report's session id and transcript, plus whether Claude Code's marker CLAUDE_PROJECT_DIR is set (a boolean, never its value). One rule in routes/activity.ts judges them at the identity, staged-successor and turn-activity writes: a claude-code report must name <session id>.jsonl, and a codex report counts as another runtime only when it carries Claude Code's marker and names <session id>.jsonl, so a Codex seat's own legacy rollout name still counts. A report from an older relay without these fields keeps today's behaviour. A helper's permission request can still appear as the seat's observed permission stop, and a helper of the seat's own runtime isn't told apart. Follows #2740; reported by pr0xc3nt4ur1 in #2702.
2026-11-08 01:09:49 -08:00
Mike Schwarz f225192e4e fix(daemon): a Codex helper inside a Claude seat keeps the seat's resume token (#2740)
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.
2026-11-07 23:49:30 -08:00
Mike Schwarz 7d15cefc8e fix(workflow): keep a run's root objective on its instance (#2738)
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.
2026-11-07 22:37:50 -08:00
Mike Schwarz cab7f569fb feat(mcp): a person answers the team's questions from their assistant (#2735)
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.
2026-11-07 21:48:30 -08:00
w4rc0n 78d73bc7b3 fix(daemon): never start the daemon or a seat with a herdr-core terminal's scope (#2737)
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.
2026-11-07 21:45:44 -08:00
Mike Schwarz ba8c01467a docs: carry the 0.7.10 capability map and agent-spec teaching onto main (#2736)
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.
2026-11-07 21:11:39 -08:00
Mike Schwarz aa0599cd41 fix(daemon): a cross-host handoff says when it overrode a claim (#2734)
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.
2026-11-07 20:20:34 -08:00
Mike Schwarz 8a46d8fabc docs(plugin): record exactly what the person typed as their answer (#2733)
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.
2026-11-07 19:49:06 -08:00
Mike Schwarz 59416773ce fix(tui): neutral names in the demo stream events (#2724)
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.
2026-11-07 19:44:00 -08:00
Mike Schwarz 39c4f9fd6e feat(plugin): marketplace files at the repo root, a square logo, and Codex listing metadata (#2719)
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.
2026-11-07 18:43:23 -08:00
Mike Schwarz 0f88021f4a docs(plugin): give a VS Code or Cursor agent a seat with its own server, turning the plugin's off (#2718)
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.
2026-11-07 18:31:35 -08:00
Mike Schwarz 1bcbef7e3f build: pack the ACP launcher beside the CLI, at one version, with hashes (#2715)
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.
2026-11-07 17:42:43 -08:00
Tingri Lohar Mohammed Mohsin 73ad2c4e07 fix(refocus): trace work in a catalogued project outside workspace.root (#2717)
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.
2026-11-07 17:37:18 -08:00
billbenetti 92a1c738fc fix(daemon): the compaction gate survives a restart, and a stale sample of another conversation ends an unready map (#2682 follow-up) (#2716)
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.
2026-11-07 17:22:03 -08:00
Mike Schwarz 937acb681b docs(acp): rig acp serve uses rig's daemon, which OPENRIG_URL can name (#2714)
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.
2026-11-07 17:06:18 -08:00
James B. Shields 1be8615a33 fix(skills): bound catalog Git reads to five seconds (#2713)
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).
2026-11-07 17:02:26 -08:00
Mike Schwarz 172685c892 fix(cli): rig acp serve says in the chat when a question ends unanswered (#2712)
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.
2026-11-07 16:48:16 -08:00
James B. Shields 2d0bd8b10e fix(handover): confirm Claude rebuild packet submission (#2650)
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.
2026-11-07 16:45:29 -08:00
Mike Schwarz 4a18628c8a fix(daemon): reconcile a restored seat once it's proven resumed (#2711)
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.
2026-11-07 16:43:11 -08:00
billbenetti f554785b9d fix(daemon): hold a prepared managed compaction until its window instead of re-requesting (#2682) (#2698)
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.
2026-11-07 16:11:47 -08:00
Mike Schwarz 1898ac9f56 fix(mcp): tell a seatless plugin agent how its app can get a seat (#2709)
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.
2026-11-07 15:56:50 -08:00
billbenetti 759d309483 perf(activity): OPENRIG_SEAT_POLL_INTERVAL_MS slows the seat-activity pollers (#2688)
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.
2026-11-07 15:42:19 -08:00
billbenetti b270e55dc8 fix(outbox): at a full held quota, coalesce a sender's own held messages into its newest (#2430) (#2701)
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.
2026-11-07 15:35:22 -08:00
Mike Schwarz f91a1f681b fix(cli): rig acp serve after its first T3 Code run (#2708)
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.
2026-11-07 15:31:30 -08:00
billbenetti 856650a129 perf(daemon): batch the stuck sweep's per-row reads and yield the event loop (#2649)
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.
2026-11-07 14:57:26 -08:00
billbenetti 5e5b328e7c perf(daemon): prune the history-only event types from events (retention.events_days) (#2699)
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.
2026-11-07 14:38:41 -08:00
Mike Schwarz 7ae8f6ef5b fix(tmux): say when tmux control mode is off, and how to move a team onto it (#2707)
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.
2026-11-07 14:34:25 -08:00
Mike Schwarz 7f3b85e993 fix(mcp): treat an empty or unfilled seat as no seat (#2657)
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.
2026-11-07 14:16:12 -08:00
Mike Schwarz a979ac5b95 fix(workflows): a running workflow keeps its spec when another file reuses its id and version (#2696)
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).
2026-11-07 13:41:19 -08:00
Mike Schwarz 45c85ec971 fix(terminal): clean the follow explanation strip (#2706)
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.
2026-11-07 13:32:59 -08:00
Mike Schwarz 2eb3c498bc feat(queue): read a code result's exact pair from its artifact (#2659)
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.
2026-11-07 12:51:07 -08:00
Mike Schwarz b7eac810de fix(cli): rig acp serve after its first real-client run (#2686)
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.
2026-11-07 12:49:49 -08:00
Twot 6b9e725950 fix(terminal): avoid closing reattach with 1011 during broker teardown (#2671)
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.
2026-11-07 12:45:24 -08:00
billbenetti feede634a9 perf(ui): the event hub's first connection replays the newest 100 events, not the whole log (#2704)
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.
2026-11-07 12:43:30 -08:00
billbenetti 1b67f88f68 fix(transport): read the Claude composer when a statusLine or the task panel sits outside the box (#2700) (#2703)
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.
2026-11-07 12:35:07 -08:00
billbenetti 48baf465e7 perf(daemon): deliver view.changed in memory instead of storing it (#2695)
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.
2026-11-07 12:09:28 -08:00
ziadeh 7fae244b05 docs(reference): a restored seat counts as needing attention until rig seat clear-attention (#2674)
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.
2026-11-07 12:02:29 -08:00
Mike Schwarz bb417260e1 feat(plugins): an Agent Plugins 1.0 root manifest for the openrig plugin (#2683)
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.
2026-11-07 12:00:22 -08:00
Mike Schwarz 6375c58819 fix(host): a pairing request stays open as long as rig host pair waits (#2692)
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).
2026-11-07 11:53:30 -08:00
Mike Schwarz d17cab58cf feat: @openrig/acp, the launcher the ACP registry starts, and acp.to (#2655)
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.
2026-11-07 11:51:59 -08:00
Mike Schwarz 0401c79078 fix(terminal): preserve follow pane identity across shell startup changes (#2663)
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.
2026-11-07 11:47:50 -08:00
Seán McNamara 33ba747047 perf(daemon): skip native enrichment for idle-shell proof (#2690)
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.
2026-11-07 11:46:45 -08:00
Seán McNamara a5cedb48f6 fix: clean up editor timers and stabilize verification tests (#2689)
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.
2026-11-07 11:45:33 -08:00
Seán McNamara 18314f9b18 test(daemon): separate native Codex final reads from polling budget (#2681)
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.
2026-11-07 11:44:39 -08:00
Seán McNamara 1336a02e6d test: make prompt episode fixture chronology deterministic (#2685)
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.
2026-11-07 11:43:42 -08:00
billbenetti 88debfdb64 fix(daemon): don't report a claim as stalled while its owner runs a background shell or awaits its managed restore (#2675)
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.
2026-11-07 11:41:30 -08:00
Seán McNamara 8379221f3e test: publish pane isolation status receipts atomically (#2673)
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.
2026-11-07 11:40:21 -08:00
billbenetti 53713ad284 feat(workflow): add rig workflow unregister with --prune-missing (#2635) (#2676)
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.
2026-11-07 11:37:30 -08:00
billbenetti c5a2be5614 feat(cli): queue show, list, transitions, update, claim and block take --host <id> (#2678)
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.
2026-11-07 11:36:00 -08:00
billbenetti 3e41daea2d perf(daemon): route targeted set-option and short one-line buffers through the tmux control client (#2636)
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.
2026-11-07 11:34:30 -08:00
Aummadour 0344edd0d6 fix(restore): keep local hook selection unknown without source evidence (#2617)
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.
2026-11-07 11:21:09 -08:00
Mike Schwarz a0f410e161 fix(transport): read the input box for drafts the pane classifier misses (#2653)
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.
2026-11-07 05:14:13 -08:00
billbenetti a34cb55ad7 fix(cli): add --full to rig workflow trace so a long trail comes back complete (#2656) (#2667)
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.
2026-11-07 05:04:32 -08:00
Mike Schwarz 642be39e5b feat(cli): read immutable context reconstructions locally (#2207)
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.
2026-11-07 05:00:30 -08:00
Mike Schwarz 8edc076d81 fix(daemon): retire a Claude seat's earlier copies of openrig-core's skills at its next launch (#2660)
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.
2026-11-07 04:58:30 -08:00
Mike Schwarz b1bed9f8c2 fix(daemon): don't compare a resumed seat's predecessor model with its pin (#2664)
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.
2026-11-07 04:48:33 -08:00
Mike Schwarz 772fa5c5fb feat(plugins): an openrig plugin for a person's own coding agent (#2647)
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.
2026-11-07 04:44:19 -08:00
NIKHIL c26af88dc7 fix(transport): read Claude's own-row "Restart to update" banner as a status line (#2662)
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.
2026-11-07 04:40:13 -08:00
Mike Schwarz 6fa0ad5c6e feat(cli): rig acp serve asks the person the team's questions and reopens sessions (#2651)
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.
2026-11-07 04:37:10 -08:00
Mike Schwarz f5b62726ff feat(transport): transport.draft_delivery lets ordinary sends reach a seat with a draft (#2645)
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.
2026-11-07 03:26:18 -08:00
Samuel 30b9556355 feat(gateway): add authenticated loopback text relay (#2624)
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.
2026-11-07 03:14:50 -08:00
Mike Schwarz bfbe416861 fix: qualify bare external attach names with their rig (#2641)
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.
2026-11-07 02:49:24 -08:00
Wu Shuwen 00cb932c0f fix(cli): report masked configuration overrides (#2547)
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.
2026-11-07 02:11:39 -08:00
Mike Schwarz 73638fcddd feat(cli): rig acp serve, OpenRig as an agent in ACP apps (#2648)
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.
2026-11-07 02:09:44 -08:00
Mike Schwarz 0c57bdb38c fix(terminal): preserve styling in snapshots and scroll-back (#2654)
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.
2026-11-07 02:07:43 -08:00
Wu Shuwen 9b1d94d6f0 docs(reference): clarify bearer and browser boundary (#2652)
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.
2026-11-07 01:39:01 -08:00
Seán McNamara 378d25301d fix(cli): avoid cold compile-cache writes for piped bare usage (#2643)
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.
2026-11-07 01:07:22 -08:00
Seán McNamara b1065870f6 feat(pi): switch accounts and preserve selection through the seat lifecycle (#2630)
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.
2026-11-06 23:52:50 -08:00
Mike Schwarz 892eb55b5e fix(transport): leave a pane's mode through the seat's address (#2644)
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.
2026-11-06 23:48:10 -08:00
Seán McNamara cfbe5372db fix(claude): pass short permission-hook policy names (#2640)
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.
2026-11-06 23:37:50 -08:00
Mike Schwarz 09781df0ae fix(codex): keep OpenRig's command folder on managed seats' tool PATH (#2613)
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.
2026-11-06 22:50:43 -08:00
Wu Shuwen 36b0ae6464 fix(cli): refuse daemon start when a probe is blocked (#2502)
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.
2026-11-06 22:18:20 -08:00
Mike Schwarz 126c52ab50 fix(mcp): route first tasks to a trusted configured receiver (#2638)
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.
2026-11-06 22:06:19 -08:00
Seán McNamara 46ce2bd860 feat: refresh opted-in seat skills on restore for Claude and Codex (#2626)
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.
2026-11-06 21:42:35 -08:00
Mike Schwarz 6a46172df1 fix(terminal): keep Herdr views from earlier releases after upgrading (#2634)
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.
2026-11-06 20:37:10 -08:00
Seán McNamara 5049550203 test: stabilize daemon fixtures under startup contention (#2628)
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.
2026-11-06 20:15:53 -08:00
Seán McNamara d1688735d5 fix(daemon): canonicalize new inherited tmux homes before recording (#2633)
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.
2026-11-06 20:04:40 -08:00
w4rc0n c271299f55 feat(ui): answer an ask's questions on its For You card (#2612)
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.
2026-11-06 16:38:42 -08:00
Mike Schwarz 9a9205da2a docs(roadmap): name 0.7.9 as the current release (#2623)
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.
2026-11-06 16:16:38 -08:00
Mike Schwarz ce6b1670b0 docs(queue): name cross-host command in custody advisory (#2621)
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.
2026-11-06 15:24:11 -08:00
Samuel abd515021f feat: add local Pi and Codex launch wrappers (#2609)
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.
2026-11-06 15:06:15 -08:00
Mike Schwarz 4697977a17 fix(daemon): stop replaying failed wakes to seats with no session at start (#2620)
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.
2026-11-06 13:54:50 -08:00
Mike Schwarz 1ff76d5954 fix(cli): name the safe retry when rig stream emit times out (#2619)
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.
2026-11-06 13:22:01 -08:00
Mike Schwarz d539277a5f fix(terminal): let writable tiles follow their visible size (#2614)
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.
2026-11-06 12:38:33 -08:00
Mike Schwarz bf5e8d8c21 fix(daemon): warn about unknown agent profile uses keys (#2618)
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.
2026-11-06 12:21:35 -08:00
Mike Schwarz b75ab56552 docs(changelog): carry the published 0.7.9 section onto main (#2616)
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.
2026-11-06 11:43:34 -08:00
Wu Shuwen 033587c042 fix: validate agent profile resource selections (#2585)
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.
2026-11-06 11:31:36 -08:00
Mike Schwarz ab7f32e096 chore(release): bind the packaged-plugin baseline to published 0.7.9 and advance openrig-core to 0.1.26 (#2615)
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.
2026-11-06 11:17:20 -08:00
Mike Schwarz 829ddfccc7 docs(rigs): try a user-writable npm prefix before a project-local install (#2611)
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.
2026-11-06 09:38:15 -08:00
Mike Schwarz c97cd5c964 fix(events): page the notification drain (#2610)
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.
2026-11-06 08:20:25 -08:00
Mike Schwarz b571206ffd fix(daemon,cli): show agent spec advisories at launch and in rig agent validate (#2606)
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).
2026-11-06 06:55:58 -08:00
Samuel f4d1d962a9 docs(telemetry): define API contract and link external exporter (#2589)
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.
2026-11-06 05:59:17 -08:00
Wu Shuwen f68d28e417 docs: clarify Claude core plugin skill loading (#2584)
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.
2026-11-06 04:40:15 -08:00
Mike Schwarz ff49a2c362 docs: say a config fragment cannot install a Codex profile, and how to keep one (#2604)
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.
2026-11-06 02:57:33 -08:00
Mike Schwarz 06a0663b42 fix(openshell): use confirmed engine cleanup inventories (#2603)
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.
2026-11-06 02:52:35 -08:00
Mike Schwarz 3a31311795 fix(cli): arm rig exec --wait's Ctrl-C before announcing the run (#2601)
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.
2026-11-06 02:32:50 -08:00
Mike Schwarz 6e55271447 fix(openshell): preserve supplied guest CA references (#2602)
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).
2026-11-06 02:29:19 -08:00
Devulapalli Naga Sri Vaishnavi b12934a53f fix(cli): route self-qualified sends to the local host (#2600)
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.
2026-11-06 01:27:20 -08:00
Mike Schwarz b79c76f0b6 feat(cli): rig exec runs a row's task on Claude Code or Codex in a detached connector (#2597)
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.
2026-11-06 00:40:34 -08:00
Aummadour 430b676727 fix(queue): treat informational human updates as readable correspondence (#2586)
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.
2026-11-05 23:40:16 -08:00
Mike Schwarz 86c677837b fix(daemon): leave every tmux mode before a delivery (#2594)
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.
2026-11-05 23:28:05 -08:00
Mike Schwarz 77d6cfa09c docs: run Claude Code and Codex through rig acp run with their ACP adapters (#2596)
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.
2026-11-05 22:51:56 -08:00
Mike Schwarz f78bbe759f docs: say what has run with a real dsh, and at what scope (#2595)
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.
2026-11-05 22:36:57 -08:00
NIKHILA cfa6118815 fix(daemon): resolve authored recap by bound session (#2579) (#2593)
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.
2026-11-05 21:35:34 -08:00
billbenetti cdac54fef7 perf(queue): build one recovery index per listing instead of a json_each scan per row (#2588)
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.
2026-11-05 21:34:39 -08:00
billbenetti f520ed5b0d fix(refocus): report a timed-out work lookup as an advisory (#2581) (#2587)
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.
2026-11-05 21:08:25 -08:00
Mike Schwarz 42d8a74b91 docs: say what the runner contract has shown natively, and what it has not (#2592)
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.
2026-11-05 20:58:42 -08:00
Wu Shuwen 983c80f3a5 fix(cli): report conflicting roster activity as unknown (#2574)
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.
2026-11-05 19:19:03 -08:00
Wu Shuwen 8fa8e83d46 fix(refocus): report unresolved typed work as an advisory (#2582)
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.
2026-11-05 18:48:24 -08:00
Wu Shuwen 0ff8482e76 feat(slack): report local relay attempts in flight (#2569)
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.
2026-11-05 18:45:13 -08:00
Mike Schwarz bd39da9872 fix(compaction): deliver a held post-compaction step after two idle unreadable polls (#2576)
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.
2026-11-05 13:03:09 -08:00
Mike Schwarz 74ca621314 fix(daemon): read the execution view's review registry once per read (#2578)
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.
2026-11-05 12:52:54 -08:00
Aummadour 143bac76ef fix(daemon): identify terminal bearer authentication failures (#2577)
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.
2026-11-05 12:42:24 -08:00
Mike Schwarz 73cf8138e7 docs: teach what 0.7.9 changed in the map, references and three help texts (#2571)
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.
2026-11-05 09:19:51 -08:00
Mike Schwarz af4aff14fe fix(cli,daemon): name pre-0.7.9 handovers and unconfirmed broadcast recipients (#2572)
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.
2026-11-05 09:19:07 -08:00
Aummadour 4168fb468d Show sanitized permission request observations in seat status (#2562)
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.
2026-11-05 07:47:09 -08:00
Mike Schwarz 781b5fbf64 docs(capability-map): teach what 0.7.8 added and drop three sentences it made false (#2568)
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.
2026-11-05 06:46:36 -08:00
Mike Schwarz ec926c2e2d fix(cli): bound doctor cmux status and topology reads (#2564)
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.
2026-11-05 05:06:45 -08:00
Mike Schwarz e0a285968d fix(daemon): stream intake reads past default-permit records (#2563)
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.
2026-11-05 04:56:41 -08:00
Wu Shuwen fe7e5393a5 fix(tui): preserve daemon launch failure messages (#2556)
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.
2026-11-05 04:35:21 -08:00
Wu Shuwen d84aeac91a test(daemon): wait for shell before transport probe (#2559)
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.
2026-11-05 04:05:32 -08:00
Mike Schwarz 911c407d46 fix(daemon): say what other words do on a runner permission ask (#2561)
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.
2026-11-05 03:40:24 -08:00
Wu Shuwen f7113e5a5d fix(cli): bound doctor health header probes (#2549)
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.
2026-11-05 03:37:15 -08:00
Wu Shuwen 80e568e9d9 fix(activity): expire stale window sampling evidence (#2558)
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.
2026-11-05 03:01:53 -08:00
Mike Schwarz 0042a91277 feat(daemon): record each permission OpenRig grants a runner by default (#2361)
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.
2026-11-05 02:51:20 -08:00
Wu Shuwen 9b9e6c4097 docs: clarify kernel provider selection at first boot (#2560)
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.
2026-11-05 02:47:31 -08:00
Wu Shuwen 512040e5c3 fix(cli): report ambiguous spec conformance targets (#2550)
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.
2026-11-05 02:39:43 -08:00
Wu Shuwen faccc35040 fix(cli): clarify saved context pressure validation (#2553)
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.
2026-11-05 02:39:11 -08:00
Wu Shuwen 78afeaa2e2 fix(daemon): show the current reason for a retained recovery (#2552)
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.
2026-11-05 02:11:30 -08:00
Wu Shuwen acdf61532f fix(cli): reject ambiguous env rig names (#2548)
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.
2026-11-05 02:10:56 -08:00
Wu Shuwen aeb218a112 fix(cli): create namespace for URL context packs (#2546)
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.
2026-11-05 02:10:20 -08:00
Wu Shuwen d33aca86ea fix(daemon): preserve tmux 3.4 default command (#2557)
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.
2026-11-05 01:55:57 -08:00
Mike Schwarz aaaeb8edc6 chore(release): bind the packaged-plugin baseline to published 0.7.8 and advance openrig-core to 0.1.25 (#2554)
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.
2026-11-05 01:53:41 -08:00
Wu Shuwen 59247e7a8f docs: correct installer setup incomplete output (#2551)
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.
2026-11-05 01:34:37 -08:00
Mike Schwarz 16946c577a fix(daemon): the Pi runner shows a failed or cancelled compaction as such (#2537)
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.
2026-11-04 23:43:22 -08:00
Wu Shuwen 2a1e609cb1 docs(reference): clarify bundle archive determinism (#2543)
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.
2026-11-04 23:07:04 -08:00
Mike Schwarz fa3262d360 docs(roadmap): name 0.7.8 as the current release (#2534)
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.
2026-11-04 21:42:45 -08:00
Mike Schwarz 94230b17bb fix(docs): the conformance check's Ctrl-C turn stays open until the cancel arrives (#2530)
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.
2026-11-04 21:42:25 -08:00
Mike Schwarz 840aeaa687 ci: give the package test jobs 60 minutes instead of 45 (#2526)
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.
2026-11-04 21:27:48 -08:00
Mike Schwarz b21466e8ae fix(preflight): warn when login shells cannot find rig (#2522)
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.
2026-11-04 21:27:27 -08:00
Mike Schwarz 190b0de73d test(compaction): keep manual-trigger cells' deadline on a fixed clock (#2527)
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.
2026-11-04 21:24:52 -08:00
Mike Schwarz f692215383 fix(daemon): a quoted permission question above Claude's empty input box is not a dialogue (#2520)
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.
2026-11-04 20:55:31 -08:00
Mike Schwarz de3b0658a6 docs: show a client reading people and open updates (#2524)
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.
2026-11-04 20:54:16 -08:00
Luis Araya 826bc1cc5c feat(stream): hand queue worker intake items on emit (#1856) (#2321)
`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.
2026-11-04 20:05:19 -08:00
Wu Shuwen 3f0a5cc744 feat(queue): expose open human updates to clients (#2497)
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.
2026-11-04 20:02:12 -08:00
Mike Schwarz 7d7634947d docs: 0.7.8 is published (#2519)
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.
2026-11-04 20:00:19 -08:00
Kai 7c5c514457 fix(daemon): recognize the current Claude Theme picker (#2514)
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.
2026-11-04 19:30:39 -08:00
Mike Schwarz 78978dcb21 fix(daemon): accept a proven Claude behind a shell label when a classic resume settles (#2518)
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.
2026-11-04 19:27:16 -08:00
Wu Shuwen 2eddb4a2d7 fix: report unconfirmed guarded submissions (#2509)
`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.
2026-11-04 19:01:42 -08:00
Wu Shuwen 91d21bc962 fix(activity): recognize live Codex working frames (#2500)
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.
2026-11-04 19:00:32 -08:00
Mike Schwarz 3448150528 test(daemon): pin a fresh Claude's login refusal through to the node view (#6) (#2405)
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.
2026-11-04 18:30:52 -08:00
Wu Shuwen 889b804ada fix(daemon): recheck pane after native identity proof (#2512)
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.
2026-11-04 18:19:05 -08:00
Aummadour 863c0f58f2 fix(package): preserve daemon asset modes and relative links (#2511)
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.
2026-11-04 17:47:27 -08:00
Wu Shuwen 87297f0f8e fix(walk): report unconfirmed delivery and submit outcomes (#2510)
`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.
2026-11-04 17:33:04 -08:00
Wu Shuwen de47425168 fix: preserve uncertain conditional send receipts (#2505)
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.
2026-11-04 16:05:51 -08:00
Wu Shuwen 101e5e3ff6 fix: preserve uncertain broadcast delivery receipts (#2507)
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.
2026-11-04 16:04:24 -08:00
Mike Schwarz 4ebb06059a fix(tui): the selected-row highlight shows in 16-colour mode (#2506)
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.
2026-11-04 16:01:44 -08:00
Mike Schwarz a7bd8fe50f fix: a Slack transport error never carries the bot token (#2503)
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.
2026-11-04 15:28:30 -08:00
Mike Schwarz d9b09ec3f2 fix(delivery): route replies to forwarded people through the queue (#2504)
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.
2026-11-04 15:16:13 -08:00
Mike Schwarz a3e5e92112 feat: list Slack channels and people through the daemon (#2501)
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).
2026-11-04 14:52:30 -08:00
Mike Schwarz 7b02076427 fix(tui): body text and the codex mark read on a light terminal (#2499)
`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.
2026-11-04 14:47:48 -08:00
Mike Schwarz e10b8014f6 fix(daemon): re-apply activity hooks on a contained resume for local: agents too (#2479)
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.
2026-11-04 14:37:46 -08:00
Mike Schwarz 23daf8513a feat: read a rig's outbox in one request (#2496)
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).
2026-11-04 13:28:37 -08:00
Wu Shuwen f7206db6de feat(gateway): expose participant registry to clients (#2495)
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.
2026-11-04 12:50:29 -08:00
Mike Schwarz 74501bdf63 docs(slack): name the private-channel route the connector already accepts (#2494)
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.
2026-11-04 12:03:15 -08:00
Mike Schwarz 432a6bcc41 docs: what a daemon client needs that only the source said (#2490)
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.
2026-11-04 12:00:51 -08:00
Mike Schwarz c4b1181dc4 fix(delivery): give human aliases a usable queue reply hint (#2493)
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.
2026-11-04 11:49:43 -08:00
Mike Schwarz 72a124de82 docs: rig host pair needs a bearer token on the target daemon (#2491)
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.
2026-11-04 11:41:38 -08:00
Mike Schwarz 499dfe3ece docs: what a checked stop sent, and what the status words count as (#2489)
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.
2026-11-04 11:22:56 -08:00
Mike Schwarz 9b3f91ecae fix: scope audit names a slice another mission also uses (#2492)
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.
2026-11-04 11:01:45 -08:00
Mike Schwarz 8baec13548 fix: host status for an ssh host dials where ssh connects (#2488)
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.
2026-11-04 10:51:49 -08:00
Aummadour f8ba5c4bac fix(daemon): preserve verified bundle file modes under a restrictive umask (#2475)
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.
2026-11-04 10:04:51 -08:00
Mike Schwarz 3917d119de fix(watchdog): retain undelivered periodic occurrences through bounded recovery (#2478)
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.
2026-11-04 09:49:41 -08:00
Aummadour 7ae36414ed test(daemon): make the recorder timeout fixture deterministic (#2476)
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.
2026-11-04 06:31:55 -08:00
Wu Shuwen a0242b206f docs(reference): correct Pi selector example path (#2477)
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.
2026-11-04 05:36:12 -08:00
Nash Aschenbath d7e2b5ca63 fix(daemon): retain the kernel variant from bootstrap history (#2471)
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.
2026-11-04 05:19:10 -08:00
Mike Schwarz bb007efabc fix(slack): mark original asks handled when their rows settle (#2472)
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.
2026-11-04 00:20:24 -08:00
Mike Schwarz 8e85902060 fix(daemon): a handover's successor is pointed to its startup files, role first (#2474)
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.
2026-11-03 23:30:48 -08:00
NIKHILA c7f5fc9674 fix(daemon): recognize Claude idle footer rows (#2470)
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.
2026-11-03 23:26:51 -08:00
Mike Schwarz 5fb816f2b9 fix(daemon): mark a handover's successor that started without a required startup file (#2466)
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.
2026-11-03 22:17:38 -08:00
Mike Schwarz 5f31cb5760 fix(daemon): refresh stale managed guidance blocks, never overwrite a person's edit (#2461)
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.
2026-11-03 21:33:34 -08:00
Mike Schwarz 886b9d0586 test(daemon): guard health and queue reads during nodes bursts (#2469)
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.
2026-11-03 20:52:01 -08:00
Nash Aschenbath 1fa46a6118 feat: a Claude member's own plugin channels (--channels) (#2439)
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.
2026-11-03 17:42:51 -08:00
Nate Faerber be85d82546 fix(daemon): open grid filler panes in the account's login shell (#2465)
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.
2026-11-03 17:13:58 -08:00
Nate Faerber 7cabac4432 fix(daemon): size a fresh seat to its first writable tile (#2464)
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.
2026-11-03 16:51:12 -08:00
Mike Schwarz e935df86f1 fix: honor accepted queue closure re-estimates without reclaiming work (#2460)
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.
2026-11-03 16:45:15 -08:00
Mike Schwarz 6fb1e2c1c5 test(daemon): concurrent same-name import cells wait for the hold, not a 1 s poll (#2463)
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.
2026-11-03 15:59:29 -08:00
Mike Schwarz 91ad6e719f docs: name the directories the spec library scan skips (#2462)
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.
2026-11-03 15:45:28 -08:00
Mike Schwarz fe29a92e44 fix(daemon): put a seat's guidance back before any runtime's handover successor launches (#2458)
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.
2026-11-03 15:24:04 -08:00
NIKHIL ab858f691b fix(tui): read a seat's own window for RECENTLY FINISHED (#2459)
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.
2026-11-03 15:16:27 -08:00
Mike Schwarz 18b5d79c9d fix(rigspec): add only the rig layers there are to the spec a seat launches with (#2457)
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.
2026-11-03 14:34:45 -08:00
Mike Schwarz c9b1ae480f fix: bound tmux client execution and kill stalled calls (#2452)
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.
2026-11-03 11:57:36 -08:00
NIKHILA 076c710d42 feat(specs): make built-in inventory visibility configurable (#2379)
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.
2026-11-03 10:40:04 -08:00
Mike Schwarz e7e6dc4172 feat(mission-control): an opt-in decision revision on the settling verbs (#2448)
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.
2026-11-03 09:38:14 -08:00
Devulapalli Naga Sri Vaishnavi 2128bb85d1 fix(daemon): bound the Pi Node version probe (#2451)
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.
2026-11-03 09:31:57 -08:00
Mike Schwarz 3352bee0d6 fix(delivery): a guarded wake recovers its own paste when the input can't be read before Enter (#2447)
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.
2026-11-03 09:13:32 -08:00
Mohit Sharma 94b01e5c88 fix(openrig-upgrade): use node:sqlite for database backup when sqlite3 CLI is absent (#2394)
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.
2026-11-03 09:05:20 -08:00
Mike Schwarz 79e5b7952d test(compaction): keep armed-map waits on the fixture clock (#2450)
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.
2026-11-03 09:01:44 -08:00
Nash Aschenbath ea4c178126 fix(cli): skip the experiment config owner check where getuid is unavailable (#2449)
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.
2026-11-03 07:01:36 -08:00
Mike Schwarz 98f0f4939c fix(compaction): read small panes at a readable size and retain end reasons (#2445)
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.
2026-11-03 06:15:44 -08:00
Dery Ferdika 9819da9d5e test(scenarios): cover queue handoff, block, resolve and unclaim transitions (#2389)
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.
2026-11-03 04:35:12 -08:00
Mike Schwarz 3fe80b97f6 fix(delivery): tell swapped occupants about held messages, and let every handoff pass a full held quota (#2442)
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.
2026-11-03 03:23:38 -08:00
Mike Schwarz ac7a6b0713 fix(handover): put a Codex seat's guidance back before its successor launches (#2444)
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.
2026-11-03 03:15:29 -08:00
Mike Schwarz cb3fb1890d docs(startup): where a fresh Codex or Pi seat's guidance goes, and how a shell-set CODEX_HOME is checked (#2443)
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.
2026-11-03 02:14:55 -08:00
Nash Aschenbath a0622b63fd fix(seat): set-typing-guard applies from an ordinary terminal, recorded as unattributed (#2351)
`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.
2026-11-03 01:40:43 -08:00
Mike Schwarz 0f6e6ea8f3 fix(codex): a fresh Codex seat's managed guidance arrives as launch developer instructions (#2441)
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.
2026-11-03 01:08:27 -08:00
Mike Schwarz 1593295db6 fix(delivery): accept Claude's wrap points, which fall short of the input box (#2436)
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.
2026-11-03 00:51:00 -08:00
Mike Schwarz e3c461a7e3 fix(startup): tell a seat that starts with held messages about them (#2437)
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.
2026-11-03 00:50:10 -08:00
Mike Schwarz c9e73d363c fix(delivery): keep one held reminder per watchdog job, and let a handoff through a full held quota (#2438)
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.
2026-11-03 00:34:16 -08:00
Mike Schwarz 51c2bd9d4d fix(cli): scope slice ls and mission ls tell shipped, superseded and closed apart (#2434)
`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.
2026-11-03 00:29:40 -08:00
Mike Schwarz 6a35cd39f3 fix(daemon): load the vendored openrig-core plugin's skills once on Claude seats (#2433)
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.
2026-11-03 00:13:38 -08:00
Mike Schwarz 85881245b4 fix(typing guard): read a Claude suggestion with plain spaces between its words as empty input (#2435)
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.
2026-11-02 23:55:36 -08:00
Mike Schwarz e5cd19e25a fix(seat): record launched handover successors as managed (#2440)
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.
2026-11-02 23:40:35 -08:00
Mike Schwarz 9d2d0d47d8 fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md (#2432)
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.
2026-11-02 23:19:22 -08:00
genius8267 7b858418f3 fix(cli): mark cut queue previews inside the body (#2422)
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.
2026-11-02 21:15:44 -08:00
Mike Schwarz d7fc1fce0c fix(slack): a click on a re-parked ask records a new answer for that park and wakes its asker (#2421)
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.
2026-11-02 20:34:26 -08:00
Mike Schwarz fa257fa12c fix(feed): say a person with no connector wasn't sent an ask in Slack (#2419)
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.
2026-11-02 20:21:25 -08:00
Mike Schwarz ba8d259966 fix(seat): a handover's receipt says whether its lineage record was written and how the old process ended (#2420)
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.
2026-11-02 20:06:56 -08:00
Mike Schwarz b35c2c5e93 fix: deliver maintained environment startup to the guest worker (#2418)
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.
2026-11-02 19:47:28 -08:00
Mike Schwarz 72d321228f fix(dsh example): read DSH_SEAT_BIN and DSH_SEAT_HOME, which a rig spec can set, and report dsh's real version (#2414)
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.
2026-11-02 18:49:26 -08:00
Mike Schwarz a7683fd14d feat(daemon): any registered person settles another's ask as themselves, and a person may have no connector binding (#2386)
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.
2026-11-02 18:28:41 -08:00
Mike Schwarz 5de5c851aa fix: detect hook review in shell-backed kernel Codex panes (#2416)
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.
2026-11-02 17:19:05 -08:00
w4rc0n 9e8ce9e078 fix(ui): For You no longer closes an ask that has questions with Approve (#2415)
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.
2026-11-02 17:09:04 -08:00
w4rc0n 4f61275582 fix(ui): keep the maintenance notice above the header, not over it (#2412)
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.
2026-11-02 16:51:07 -08:00
w4rc0n cd01d2ac34 fix(ui): the Dashboard kernel card follows the dark theme (#2413)
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.
2026-11-02 16:29:10 -08:00
w4rc0n 6a2a4855b0 fix(ui): typing in the Topology table filter no longer hangs the tab (#2411)
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.
2026-11-02 16:28:33 -08:00
Mike Schwarz 95f0ae6d67 fix(compaction): report manual idle-wait expiry by map readiness (#2400)
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.
2026-11-02 16:06:03 -08:00
Mike Schwarz 0ed456e68f docs(openshell): use the actual kernel operator address (#2404)
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.
2026-11-02 15:03:03 -08:00
Mike Schwarz f913586a38 docs: make the in-repo docs and two help texts match what 0.7.8 ships (#2403)
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.
2026-11-02 14:52:28 -08:00
Mike Schwarz 8fca221aab fix(kernel): start a new kernel in the workspace's own kernel folder (#2402)
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.
2026-11-02 14:21:42 -08:00
Mike Schwarz 0b60f8da4a feat(cli): assistants connected through a grant service work with each other and the team (#2384)
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.
2026-11-02 14:08:23 -08:00
Mike Schwarz fbca740dd1 feat(daemon): Pi's stream beat becomes the runner contract's working beat (#2399)
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.
2026-11-02 13:59:26 -08:00
Mike Schwarz 522d635cdf docs(slack): correct missed-message recovery channel coverage (#2401)
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.
2026-11-02 13:36:26 -08:00
Mike Schwarz 8834943465 feat(docs): the dsh example runner beats, and the conformance check orders idle reports by the runner's sequence (#2388)
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.
2026-11-02 13:24:09 -08:00
Aditya Chandrajit Deore b9a4390b96 fix(daemon): recover current Claude hooks when saved startup context is absent (#2373)
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.
2026-11-02 12:19:22 -08:00
NIKHIL c3eb2f73e5 fix(tui): pick JUST FINISHED by finish time so an old item closed last shows (#2392)
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.
2026-11-02 11:37:38 -08:00
JUSHUANGHUI LI d942e12954 fix(daemon): avoid rescanning long transcript lines during grep (#2397)
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.
2026-11-02 11:33:09 -08:00
NIKHIL c1783989c8 fix(tui): show JUST FINISHED clocks in the configured time zone (#2393)
`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.
2026-11-02 11:29:35 -08:00
JUSHUANGHUI LI 551fb0e2f7 fix(daemon): report malformed optional workspace frontmatter (#2396)
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.
2026-11-02 11:28:42 -08:00
JUSHUANGHUI LI d1839dfcf1 fix(cli): keep progress edits out of fenced code blocks (#2395)
`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.
2026-11-02 11:26:43 -08:00
Mike Schwarz 2b929b3d76 fix(daemon): rig down takes back OpenRig's own additions once nothing uses them (#875) (#2387)
`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.
2026-11-02 00:44:55 -08:00
NIKHIL b28dcd8f21 fix(transport): stop reporting delivery on a broadcast that reached nobody (#2359)
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.
2026-11-02 00:19:58 -08:00
Mike Schwarz da9ef8273c feat(daemon): a runner's working beat keeps a quiet turn reading working for its live, open turn (#2370)
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.
2026-11-02 00:06:58 -08:00
Mike Schwarz 16ac9bfabd docs(daemon): set-runtime names the route for a seat that has no pane to switch (#2383)
`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.
2026-11-01 23:50:14 -08:00
NIKHIL 94f7b27ac1 fix(cli): normalize documented --priority values and note the rest (#2358)
`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.
2026-11-01 23:47:08 -08:00
NIKHIL 3c0fee3e2d fix(cli): warn on an unknown --state in rig queue list (#2357)
`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.
2026-11-01 23:46:31 -08:00
NIKHIL 978e95fbff fix(cli): finish a default rig export beside an existing rig.yaml (#2356)
`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.
2026-11-01 23:45:49 -08:00
Mike Schwarz b78894736d docs: 0.7.7 is published (#2385)
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.
2026-11-01 23:42:34 -08:00
Mike Schwarz cca17f54d5 feat(daemon): a person's typed words on a permission ask reach the agent (#2363)
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.
2026-11-01 22:29:21 -08:00
Mike Schwarz 7eef5d4d64 fix: repair OpenShell workspace projection and unstarted cleanup (#2378)
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.
2026-11-01 21:03:23 -08:00
Mike Schwarz 4094320619 docs: carry 0.7.8 changelog onto main (#2377)
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.
2026-11-01 20:41:35 -08:00
Tingri Lohar Mohammed Mohsin 085d59248f fix(cli): warn on unrecognized --kind in rig specs commands (#2365)
`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.
2026-11-01 20:29:57 -08:00
Mike Schwarz 22b574983e fix(daemon): re-merging a managed block no longer adds a blank line on every launch (#2376)
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.
2026-11-01 20:01:56 -08:00
Mike Schwarz f3f8319d1c fix(daemon): prove a Claude launched through the native installer's symlink by its OS path (#2371)
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.
2026-11-01 19:05:19 -08:00
Tingri Lohar Mohammed Mohsin 82438318f5 fix(cli): print readable stderr line on daemon workspace validate refusal (#2367)
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.
2026-11-01 18:56:13 -08:00
Mike Schwarz eabaaf8e34 fix(docs): the conformance check waits its per-step bound for what a runner does after a permission read (#2375)
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.
2026-11-01 18:25:24 -08:00
porterchild b95c1d6d79 fix(daemon): pi-runner stream heartbeat keeps long-running inference reading as working (#2336)
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.
2026-11-01 18:11:34 -08:00
Mike Schwarz fa2336fe23 docs(reference): name ACP session/resume in section 8, and use an agent's session list as positive evidence only (#2364)
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.
2026-11-01 17:51:01 -08:00
Mike Schwarz a2d4d261fd fix(cli): include the SDK in clean installations (#2374)
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.
2026-11-01 17:49:09 -08:00
Mike Schwarz 4a4fccfa76 docs(context-packs): a typed reply to a parked question is recorded as its answer (#2369)
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.
2026-11-01 16:08:25 -08:00
Mike Schwarz bf35051537 fix(docs): the conformance check reads an early launch record against the agent's log, and a turn's Stop in either order with its withdraw (#2368)
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.
2026-11-01 15:45:52 -08:00
Mike Schwarz 098b241dc7 docs: carry the 0.7.7 changelog onto main (#2366)
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.
2026-11-01 14:13:10 -08:00
Mike Schwarz 92ef63210f fix(daemon): a provider switch keeps the running effort instead of refusing, and says which effort it used (#2362)
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.
2026-11-01 13:11:47 -08:00
Mike Schwarz 9f99796bd7 docs(reference): describe the answered permission path where runner docs still described refusal (#2360)
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.
2026-11-01 13:00:03 -08:00
Mike Schwarz 072a6719bd fix(daemon): rebuild a Claude seat behind a non-exec launcher, and keep the resume target on a failed handover (#2341) (#2354)
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.
2026-11-01 11:46:06 -08:00
Mike Schwarz 3fb3f286a5 docs(community-harness): a dsh example runner for the runner contract (#2305)
`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.
2026-11-01 11:06:49 -08:00
Mike Schwarz b65e4ea831 feat(docs): the conformance check covers the answered permission path (#2306)
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.
2026-11-01 10:38:15 -08:00
Mike Schwarz 73a1de0b4f docs: correct migration 145 permission default comment (#2355)
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.
2026-11-01 10:29:31 -08:00
Wu Shuwen eb6c158217 docs(reference): add Pi tool-selection recovery runbook (#2186)
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.
2026-11-01 10:11:18 -08:00
Mike SchwarzandVaishnavi220506 0af3a10928 fix(daemon): match OPENRIG_WAKE_SUSPEND entries by their destination's own prefix (finishes #2114) (#2353)
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]>
2026-11-01 09:41:38 -08:00
Mike Schwarz ab02a480d1 feat: answer a runner seat's permission requests through a chosen person (#2301)
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.
2026-11-01 09:26:50 -08:00
Mike Schwarz 5b4627efa3 slack: a resolved decision notice no longer re-renders as a live ask (#2349)
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.
2026-11-01 09:00:04 -08:00
Mike Schwarz 1f068d2ee2 test(cli): use fileURLToPath instead of URL.pathname in host-bindings and restore tests (#2352)
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.
2026-11-01 08:55:07 -08:00
Mike Schwarz 860d352556 fix: preserve drafts throughout managed compaction (#2347)
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.
2026-11-01 08:33:36 -08:00
Aditya Chandrajit Deore 31a08f70bd fix(daemon): re-apply current Claude activity hooks on contained resume (#2338)
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.
2026-11-01 08:04:46 -08:00
Mike Schwarz 9fa4f2c055 fix(daemon): a provider switch keeps the seat's record true and its running effort (#2344)
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.
2026-11-01 08:01:01 -08:00
Mike Schwarz bcb2edfd11 daemon: status reports a pending start under a live reservation (#2348)
`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.
2026-11-01 07:41:04 -08:00
Devulapalli Naga Sri Vaishnavi 0389245d87 fix(cli): learn hosts with prototype-named aliases (#2346)
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.
2026-11-01 07:27:22 -08:00
Mike SchwarzandMydudeclaps 7c6746e612 fix(daemon): submit through the paste route when a read-only tmux client refuses send-keys (#2345)
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]>
2026-11-01 07:26:24 -08:00
Wu Shuwen 1c5cbf728e fix(slack): clarify typed decision replies (#2299)
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.
2026-11-01 07:25:20 -08:00
Wu Shuwen ac3406ea76 Preserve member startup declarations in rig exports (#2190)
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.
2026-11-01 07:23:54 -08:00
Ethereal49 2d7275c918 fix(daemon): keep a seat's skill copy when its source moved on (#2337)
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.
2026-11-01 07:22:42 -08:00
Devulapalli Naga Sri Vaishnavi 2f1abfa6bf fix(daemon): preserve image pin on unpin failure (#2332)
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.
2026-11-01 07:21:36 -08:00
Nash Aschenbath 97bb7d33b4 fix(seat): include process-chain evidence in restart refusals (#2330)
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.
2026-11-01 07:20:00 -08:00
Wu Shuwen b5128b1fd2 fix(tmux): avoid control mode on vulnerable servers (#2325)
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.
2026-11-01 07:19:04 -08:00
Duc 86e17cd2f8 fix(tui): separate rigs in the instance table (#2327)
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.
2026-11-01 07:18:07 -08:00
David Riott cd8f8b4f08 fix(gateway): render agent Markdown in Slack row bodies, with native lists (#2316)
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.
2026-11-01 07:16:45 -08:00
Mike Schwarz 660598e641 fix: retain prompt escalations when human delivery is unavailable (#2343)
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.
2026-11-01 07:14:34 -08:00
Mike Schwarz caeefec951 fix(daemon): a never-started seat on rig launch --seats starts (#2342)
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.
2026-11-01 07:11:29 -08:00
Mike Schwarz cd4b7c39ca Keep completed compaction maps armed through a bounded idle wait (#2323)
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.
2026-11-01 07:09:11 -08:00
Nash Aschenbath b25dd19a5a fix(daemon): end the control session with a hook, not destroy-unattached (#2317)
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).
2026-10-30 07:49:24 -07:00
Dery Ferdika 9a54284d47 feat(hosts): add rig host edit and remove (#2310)
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.
2026-10-30 06:57:27 -07:00
Devulapalli Naga Sri Vaishnavi cf50c94b1d fix(usage): retain charge post retries during lookups (#2314)
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.
2026-10-30 06:45:30 -07:00
Mike Schwarz 5a3980e893 docs(roadmap): 0.7.6 is released (#2312)
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.
2026-10-30 05:07:36 -07:00
Mike Schwarz 345410cdbb fix(daemon): check expected native conversation on reads and sends (#2309)
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.
2026-10-30 05:06:01 -07:00
Mike Schwarz ff604d943d fix(daemon): read Claude's "Update installed" footer as a status line (#2311)
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.
2026-10-30 04:49:18 -07:00
Nash Aschenbath 371a2f3916 feat(daemon): isolate managed tmux sessions per instance (#1260)
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.
2026-10-30 03:39:28 -07:00
Mike Schwarz 18e3446a06 docs(reference): the runner contract's 0.7.6 pieces are published (#2308)
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.
2026-10-30 03:04:04 -07:00
Mike Schwarz 0ccd013290 feat: add observed OpenShell lifecycle and exact native restore (#2300)
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.
2026-10-30 02:35:25 -07:00
Mike Schwarz bc5505e905 chore(release): bind the packaged-plugin baseline to published 0.7.6 and advance openrig-core to 0.1.24 (#2307)
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.
2026-10-30 02:24:58 -07:00
Mike Schwarz fbf61c7674 feat(docs): a runner conformance check runner authors point at their runner (#2303)
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.
2026-10-30 01:58:25 -07:00
Mike Schwarz 9751fd1c87 fix(cli): say when doctor's port is held by another user's OpenRig daemon (#2302)
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.
2026-10-30 01:31:06 -07:00
Mike Schwarz 369557593e Run OpenShell workers with verified context and artifact return (#2296)
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.
2026-10-29 22:56:35 -07:00
Mike Schwarz 42221ed4a5 feat(daemon): seat activity grant on the hook ingress (#2295)
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.
2026-10-29 22:17:18 -07:00
Wu Shuwen 243a41ec03 fix(export): preserve authored pod startup actions (#2297)
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.
2026-10-29 22:00:17 -07:00
Mike Schwarz ff39b77344 fix(slack): record a typed reply as the answer to a decision parked on its person (#2293)
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.
2026-10-29 18:26:35 -07:00
Mike Schwarz 270f5dde4a fix(daemon): trust OpenRig's own hooks on an install with no recorded choice (#2294)
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.
2026-10-29 17:44:58 -07:00
Wu Shuwen 5bc95cb6cd fix(queue): avoid false wake finding for local kernel human (#2286)
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.
2026-10-29 16:03:49 -07:00
Mike Schwarz ea21ade40b fix: route kernel Codex hook review through prompt escalation (#2290)
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.
2026-10-29 15:45:16 -07:00
Mike Schwarz b59593f187 fix(daemon): say claude wasn't found when a launch's pane is back at its shell (#2282)
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.
2026-10-29 15:27:54 -07:00
Mike Schwarz e6cf5bf47c docs: qualify typed Slack answers for parked questions (#2292)
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.
2026-10-29 14:59:23 -07:00
Mike Schwarz 68865bc5e7 fix(cli,daemon): handover's fork source is a seat, and the dry run checks it (#2287)
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.
2026-10-29 14:43:21 -07:00
Wu Shuwen 7ba456f97c test(daemon): preserve missing identity on reconcile (#2285)
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.
2026-10-29 14:34:29 -07:00
Mike Schwarz ee41cf610c fix: preserve original viewer failures in follow notices (#2289)
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.
2026-10-29 14:30:57 -07:00
Mike Schwarz 957dd83bd1 docs: qualify slice-follow hold notice reasons (#2288)
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.
2026-10-29 14:10:18 -07:00
Mike Schwarz f8ff3a9c7b fix(daemon): say when Codex hook review is for OpenRig's own unapproved hooks (#2284)
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.
2026-10-29 14:01:43 -07:00
Mike Schwarz 8f4a5f4b3a Bound quota-full parked reminders and exclude terminal-only recipients (#2281)
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.
2026-10-29 13:10:10 -07:00
Mike Schwarz 7a5d83c31c docs: carry 0.7.6 release guidance onto main (#2283)
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.
2026-10-29 12:23:03 -07:00
Mike Schwarz c083cfe9ca fix(cli): say no row was created when the daemon refuses a new row's fields (#2280)
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.
2026-10-29 12:01:29 -07:00
Wu Shuwen f9f13dafbe Explain unverified shell delivery dependency (#2202)
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.
2026-10-29 11:12:25 -07:00
Mike Schwarz 9cba975cd0 docs: name the kernel dashboard's session from rig ps in its recovery steps (#2275)
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.
2026-10-29 10:02:17 -07:00
JUSHUANGHUI LI 4ef3295ee7 perf(daemon): avoid rescanning long transcript tails (#2274)
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.
2026-10-29 09:39:16 -07:00
Mike Schwarz cc9adef50a fix(daemon): keep a seat whose saved conversation is missing paused, and say what starting it fresh takes (#2255)
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.
2026-10-29 09:37:37 -07:00
Mike Schwarz c848ea8af1 docs: draft integration author contracts (#2272)
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.
2026-10-29 07:58:00 -07:00
Mike Schwarz 0fb897179b fix: recover terminal joins refused before split dispatch (#2271)
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.
2026-10-29 07:55:44 -07:00
Mike Schwarz ac8a3b819e fix(cli): name the step that binds a cross-host self-id in the reply hint (#2273)
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`.
2026-10-29 07:11:51 -07:00
Mike Schwarz dc2b8caa9a fix: keep a still-starting daemon after its readiness deadline (#2267)
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.
2026-10-29 06:47:48 -07:00
Mike Schwarz c86eaf394c feat: follow mission owners across indexed slices (#2260)
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.
2026-10-29 06:18:04 -07:00
Mike Schwarz f26ec35d26 fix(daemon): set a stopped Claude seat's token file for the launch that resumes it (#2269)
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.
2026-10-29 06:12:48 -07:00
Mike Schwarz 00a1856d3f docs(roadmap): mark 0.7.5 released (#2270)
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.
2026-10-29 05:45:13 -07:00
NIKHIL 8087185385 fix(tui): say an agent spec's seats weren't read instead of none (#2266)
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.
2026-10-29 05:39:06 -07:00
NIKHIL 5fa8c2078a fix(mcp): cut rig_context_get and rig_artifact_read text between whole characters (#2268)
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.
2026-10-29 05:37:59 -07:00
NIKHIL 74e6edd0e2 fix(tui): read the rig's Recent window on a pod's Recent tab (#2265)
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.
2026-10-29 05:36:21 -07:00
NIKHIL 61c4adf4c2 fix(tui): read a seat's recently finished items on its agent page (#2264)
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.
2026-10-29 05:35:12 -07:00
NIKHILA aa6c5281a3 fix(tui): load health findings on agent pages (#2263)
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.
2026-10-29 05:01:06 -07:00
Devulapalli Naga Sri Vaishnavi 6b51628a3f fix(cli): handle failed ACP cancel notes (#2262)
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.
2026-10-29 04:56:39 -07:00
Mike Schwarz 8a5aa07192 fix: place colliding slice aliases by indexed mission (#2261)
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.
2026-10-29 04:33:38 -07:00
Nash Aschenbath 93fd158960 feat(daemon): allow per-agent Claude MCP opt-out (#2177)
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.
2026-10-29 02:55:00 -07:00
Mike Schwarz b6bd4633fb Follow current slice obligation owners in terminal views (#2259)
`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.
2026-10-29 02:22:42 -07:00
Mike Schwarz 6498383a4d docs: qualify the reported Codex restart refusal in 0.7.5 (#2257)
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.
2026-10-29 01:48:52 -07:00
Mike Schwarz ad8c479ec2 docs: v0.7.5 install pins (#2256)
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.
2026-10-29 01:32:15 -07:00
NIKHIL 7462f1f9f9 fix(tui): keep emoji whole when rig table cells are cut to width (#2253)
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.
2026-10-29 01:05:06 -07:00
Mike Schwarz 89e5f9217e fix(daemon): preserve member settings during rig expansion (#2250)
`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.
2026-10-29 01:01:01 -07:00
Mike Schwarz ab5a233982 docs: carry 0.7.5's release notes and capability map to main (#2254)
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.
2026-10-29 00:26:47 -07:00
Mike Schwarz 6cc3626529 feat(daemon): a Claude member names its own subscription token in the spec (#2246)
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.
2026-10-29 00:22:55 -07:00
NIKHIL 2a73f1809a fix(tui): keep emoji whole when health and config rows are cut to width (#2252)
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.
2026-10-28 23:41:16 -07:00
NIKHIL c67b829616 fix(cli): read rig status snapshot times as UTC (#2251)
`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.
2026-10-28 23:40:24 -07:00
Rudy Mizrahi Celekli 0820fad101 fix(ui): show completed mission labels after proof acceptance (#2249)
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.
2026-10-28 23:39:09 -07:00
Nash Aschenbath 3c7cfe26ca docs(reference): explain shared kernel dashboard recovery (#2213)
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.
2026-10-28 22:33:29 -07:00
Mike Schwarz 75602eb977 fix(daemon): stop projecting retained dead panes as running (#2248)
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.
2026-10-28 22:27:22 -07:00
Seán McNamara ef82979f12 fix(codex): recognize verified Node launchers during in-place restart (#2247)
`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.
2026-10-28 21:57:57 -07:00
Seán McNamara d1cdfad212 feat(provider): resume Claude seats with API-key credentials (#2191)
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.
2026-10-28 21:38:27 -07:00
Mike Schwarz 2f6263b7dc feat(slack): let reaction routing be turned off (#2241)
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.
2026-10-28 21:01:10 -07:00
Mike Schwarz 0fcb7040e1 fix(slack): record where Slack actually placed a reply (#2240)
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.
2026-10-28 20:25:19 -07:00
Seán McNamara e61faf5d80 feat(provider): switch seats to isolated account homes (#2143)
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.
2026-10-28 20:21:30 -07:00
Mike Schwarz 9039c86ad9 fix(slack): a button answer resumes a decision parked on the person it asked (#2245)
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.
2026-10-28 19:17:47 -07:00
Mike Schwarz 9924040ba4 feat(daemon): a community harness can take the seat's declared model (#2243)
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.
2026-10-28 18:16:21 -07:00
Mike Schwarz e3d9ce488d docs(changelog): match two 0.7.3 lines to the published release notes (#2244)
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.
2026-10-28 17:15:19 -07:00
Mike Schwarz fc50fb8d71 fix(daemon): retain protected Codex messages before an unobservable paste (#2242)
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.
2026-10-28 15:58:35 -07:00
Mike Schwarz 5a690c9c8b feat(daemon): runner presets report their seat activity (#2239)
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).
2026-10-28 15:32:36 -07:00
Mike Schwarz 03a2828ce7 fix(daemon): keep prompt alerts routable with nonlocal senders (#2237)
When a seat sits at a choice prompt and its row came from another host, the alert about it now still reaches someone, and one such row no longer stops the whole pass. The wake ladder used the row's original sender as a local destination for the alert's carrier row; a sender on another host failed local destination validation and threw, aborting the shared prompt pass, and the same choice in the ordinary ladder failed the whole tick. A shared carrier choice now picks an admissible configured operator or the original sender, otherwise the blocked seat itself, at the prompt pass, its consumed-hop continuation, and the ordinary ladder's continuation and operator-floor alert. The carrier is never nudged, the original sender stays as the row's source, and the existing human-alert policy (including explicit-only) is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

rig start --help and getting-started now say which rigs --last brings back: a rig comes back unless every seat it launched was stopped on purpose. A rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or were lost without rig down, counts as last running, and running rig down <rig> once (by id when names are shared) keeps it down. --last never selects the kernel; under --no-kernel its automatic restore is skipped too, and rig up kernel --existing brings it back.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Implements the scope agreed in #605.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported by @ziadeh.

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

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

Reported by @duncanleung.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported by smota in Discussion #2117.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3 files, +91 -1, of which 64 are test lines.
2026-10-26 08:25:32 -07:00
Mike Schwarz e08d084465 docs(claude): name the other-login helper case for rate-limit attribution (#2111)
The Claude status-line reference now names the other-login case for rate-limit attribution. A helper Claude started from a seat's shell on a different login is attributed to that seat, like a same-login helper, so its rate limit can hold pending handoffs to every seat in that seat's Claude pool for one retry interval, even when the seat's own login still has room.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

`--wait-for-idle <seconds>` is described as it behaves: if the seat is still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps nothing to send later.

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

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

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

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

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

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

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

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

No new interoperability claim is made.
2026-10-26 00:59:39 -07:00
Mike Schwarz 16d2f01b15 docs(slack): one continuing conversation keeps earlier context, whoever writes (#2083)
The Slack setup guide now says that messages routed to the same continuing agent conversation can use that conversation's earlier context, whoever sent them. A queue row records who sent a message and which seat owns it; it doesn't start a separate agent conversation.

If people need separate conversations, route them to separate seats (`--inbound` names the seat a channel's new messages reach). Separate channels help only when their routing also keeps the conversations separate. A conversation shared on purpose is fine.
2026-10-26 00:56:25 -07:00
Mike Schwarz 801779fbb4 fix(attention): the TUI shows the person at the terminal acting on a channel's row, and a channel never acts (#2068)
With a shared channel registered and no person, OpenRig records an act on the channel's ask as the person at the terminal (`human@host`). The Feed now serves that same actor on a channel's ask or a row parked on one, so the TUI's label, its success notice and the record agree.

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

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

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

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

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

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

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

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

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

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

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

## How you verified it

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

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

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

## Anything you were unsure about

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

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

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


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

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

## Summary by CodeRabbit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* test(daemon): stabilize compaction retrigger fixture clock

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What changed

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

Tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests cover single-quoted, inline and multi-line-example cases through the writers, `deliverStartup` and `ensureCodexActivityHooks`, the owned plugin's renewal, an unreadable config, and a probe-named key. Cost against main: source +77/-12 (the adapter, the owned-plugin check and the new `toml-tables.ts`), tests +134/-1. No new refusal or error code.
2026-10-25 10:48:38 -07:00
1303 changed files with 123956 additions and 7122 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
+37 -2
View File
@@ -3,7 +3,8 @@ name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites deny external networking; scenario runtime has no external route.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
on:
push:
@@ -76,12 +77,46 @@ jobs:
- run: npm ci
- run: npm run test:repo
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install the real terminal and staging tools
run: |
sudo apt-get update
sudo apt-get install --yes tmux rsync
- run: npm ci
- run: npm run build
- name: Check seat environments without ambient credentials or external network
run: |
set -euo pipefail
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
mkdir -p "$suite_root/home"
# Only loopback exists in this namespace. Drop back to the runner user
# before the CLI, private daemon and private tmux server are started.
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
set -euo pipefail
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts 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,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 45
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
+4 -3
View File
@@ -290,9 +290,10 @@ packages.
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
+2093 -4
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,14 +68,14 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.2` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
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.2/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
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:**
+2 -15
View File
@@ -6,22 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Planned for 0.7.2
## Released in 0.7.10
- **Outside assistants over MCP (if ready):** Assistants such as ChatGPT and Claude Desktop find your team, delegate
work, follow it and get the results through a maintained MCP server, locally or over an authenticated network
connection.
- **More harnesses as seats (if ready):** One plug-in point for other coding-agent CLIs, with Hermes, OpenClaw and
Cursor running as normal seats.
- **Accounts (if ready):** Each seat can use its own provider account, and an agent can move one seat to another account
and keep its conversation.
- **Subscriptions (if ready):** One Fleet agent manages two registered Codex subscriptions across two Linux machines, following
your standing preferences.
- **Member permissions (if ready):** Each seat gets its own permission rules, even when seats share a project folder.
- **Stream decisions (if ready):** Opt in, and observations on a rig's stream reach the work they belong to, with
visible results and effects you can recover.
- **Named people in Slack (if ready):** Agents can address several named people in one Slack workspace, and each person
sees and answers their own requests.
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
+2 -1
View File
@@ -49,7 +49,8 @@ COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz
RUN set -eux; \
npm install -g /tmp/openrig.tgz; \
rm -f /tmp/openrig.tgz; \
rig --version
rig --version; \
rig --help
# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the
# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) ---
@@ -281,6 +281,34 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`no_conversation_found` (`:429–434`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:444–450`) — the adapter never relaunches on its own.
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
otherwise ready history probe only for the matching fresh launch, binding and
launch generation. The current pane must show a prompt, and `assessManagedProbe`
must confirm the new launch's exact session token in the same foreground process
across two observations, with the pane and launch still current. It reuses the
existing managed identity proof, including its process fingerprint. A process
label alone, old scrollback, an unavailable screen, an unreadable process list
or an unidentified Claude process cannot admit the handover. Those cases stay
`awaiting_runtime`; a positive identity conflict and visible approval or login
gates keep their specific results. The handover waits within its existing
readiness budget before sending the context packet.
The fresh path requires the last `❯` prompt to sit inside Claude's complete
input frame: its top rule directly above the prompt and a bottom rule below.
A shell's echoed launch command cannot borrow an earlier header or input
frame, even when the matching Claude process is already running. A header
and prompt alone, or a partially drawn frame, remain pending. This may delay
readiness until the full frame is visible; other launch paths keep their
existing screen predicates.
Previously, unavailable observations and ambiguous headerless prompts could
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
missing observations, unknown or changing process identity, retained UI above
direct and script launch commands, partial frames, recovery and normal prompt
controls. `claude-handover-readiness.test.ts` exercises the real handover
service with migrated SQLite, modeled pane/process observations and one synthetic
clock. Readiness and the later packet-submission receipt remain separate checks;
these tests do not run a live Claude account.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
@@ -370,6 +398,33 @@ adapter never closes a session on it and reports `rex_outcome_unknown`.
Herdr and Rex build their layout trees from one grid helper
(`buildTileGrid`, `tile-layout.ts`).
### Tmux server selection
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
`domain/tmux-server-selection.ts`. `terminal.tmux_server` defaults to `inherit`;
`isolated` selects a socket derived from the canonical OpenRig home. The effective
choice is persisted in `tmux-server.json`. A registered live seat, or an
uninspectable old server, defers a requested change until a later restart.
The persisted `pinned` flag and active marker make isolation and migration records
mandatory, while ordinary inherited records remain advisory. Returning to inheritance
commits an unpinned record before clearing the marker, so offline cleanup cannot reuse
a stale inherited socket or keep enforcing a completed isolation choice.
`TmuxAdapter` receives the selection's `executionArgs` for both shell and argv
transports, including its direct probe/cleanup calls. They are empty for ordinary
`inherit` mode, preserving tmux's environment-based selection; `serverArgs` still
names the server for attachments. The opt-in control client uses the same execution
arguments for its connection, probes and fallback commands. The same startup choice feeds node
inventory, `TerminalService.status()` and previews, composed panes, shared TUI attachment and
CLI viewer management. Inherited SSH panes retain the remote-tmux replacement
wrapper and plain recovery command without requiring remote OpenRig. Isolated
SSH panes resolve the remote instance through its own `rig terminal attach`.
For isolation, self-attachment checks the caller's socket before binding;
offline destruction reads the persisted socket before removing state.
See [tmux server selection](../../reference/tmux-servers.md) for configuration
loading, first-upgrade migration, discovery scope and platform behavior.
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
@@ -65,6 +65,35 @@ resolve → project → deliver pre-launch files → persist replay context →
## 2. Execution and projection types
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
persisted independently in `nodes.claude_mcp` (migration 142). The
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
invalid values for the canonical validator to reject. An authored relaunch
updates the seat choice after launch eligibility and startup-source resolution;
an already-bound seat or a failed tmux launch keeps its prior value. Export
describes the current runtime; a dormant choice on another runtime is omitted
and must be restored in the Claude declaration when rebuilding from that export.
The YAML export route names those agents in the existing `# Not carried:` report;
the JSON export is unchanged. `rigspec-routes.test.ts` covers both pod-aware and
legacy exports, omission controls and successful JSON export.
The shared `StartupOrchestrator` reads the current seat record, so both an authored start
and saved-context restore pass the choice to Claude. Legacy resume and handover
successors read it separately; a runtime-switch successor reads it only when
the target is Claude. `claude-mcp.ts` builds the strict empty-server arguments
for the adapters, shutdown resume probes and displayed recovery commands,
including the conversation picker. Shutdown skips a probe with an unreadable
saved choice and keeps the resume token without adding a shutdown refusal.
The native lineage selectors recognize these exact
arguments without treating JSON contents as session options. Invalid stored
values refuse launch. With no choice, the adapters add no MCP arguments.
`claude-mcp-lifecycle.test.ts` exercises real schema/storage, export, snapshot
and restore with simulated tmux/provider processes. `claude-mcp-launch.test.ts`
executes managed and unmanaged shell commands against a private argv-printing
fake, including fresh/resume/fork and legacy resume. These tests verify command
plumbing and identity handling; they do not claim live provider enforcement.
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
spec/projection types live here.
@@ -119,6 +148,37 @@ them imports Hono: **0**
- `startup-resolver.ts` — additive startup layering.
- `projection-planner.ts` — runtime resource projection planning.
### Profile resolution during launch
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
the pod-aware apply path, materialized-member launch and first-start retry called
the synchronous resolver again on the daemon's request thread. A fallback skill
scan or managed-catalog inspection could therefore delay every other request even
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
Those three launch paths now await the same resolver in a worker before projecting
resources or starting the member. Configuration precedence and launch order still
come from the existing resolver and instantiator.
An apply operation reuses one lazy worker across its members and terminates it on
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
reads current files, including changes made by an earlier member's startup.
Standalone member launches and first-start retries use a short-lived worker for
their resolution. This adds worker startup and context-copy costs; it does not
reuse the preflight result or cache configuration between requests. The helper's
existing synchronous fallback for a deleted process working directory remains.
Worker errors become configuration-failure results. Apply can then follow the
existing all-members-failed cleanup, including service rollback and restoration
of replaced generations. Materialized launches report a failed member, and
first-start retries refuse before writing retained state.
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
requires an HTTP health response while that FIFO is still blocked, then checks
that the selected skill reaches projection. It covers all three launch paths and
a skill edit between members. These fixtures use SQLite and the real instantiator
with a simulated runtime; they do not establish native provider behavior or
responsiveness during other synchronous parts of startup.
## 4. Startup orchestration (the spec-startup contract)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
@@ -214,16 +214,26 @@ inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
(`event-bus.ts:70`) with subscribers notified after commit — across domain
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
SQLite-backed.
SQLite-backed, and it holds stored events only. `view.changed`, which the view
event bridge (`view-event-bridge.ts`) produces for each queue, inbox, proof or project-classification change,
is transient: `eventBus.publishTransient(...)` delivers it to the live
subscribers without a row, without a `seq` of its own and without moving the
notify watermark. It carries `transient: true` and, as its `seq`, the `seq` of
the stored event that caused it.
Upgrade note: `view.changed` rows stored by older versions stay in the log, so
`/api/events` replays and the telemetry export still return them until an events retention
(a separate change) removes them.
SSE delivery surfaces include the following. The daemon has **11**
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
- `GET /api/events` — global stream of all events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's. Each
event's SSE `id` is its `seq`. Before going live, a connection replays
- `GET /api/events` — global stream of the stored events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's.
Transient events (`view.changed`) are not stored, so they appear here
neither live nor in a replay. Each event's SSE `id` is its `seq`. Before going live, a connection replays
stored events, read and written 500 at a time (`routes/events.ts`):
- with a `Last-Event-ID` header (an `EventSource` sends it when it
reconnects), every event after that `seq`, whatever `replay` says;
@@ -234,6 +244,10 @@ SSE delivery surfaces include the following. The daemon has **11**
Either way the replay ends at the newest event stored as the connection
subscribes; the events stored meanwhile were buffered and follow it, each
once and in order, before the stream goes live.
- `GET /api/views/sse` (alias `/api/views/watch`) and
`GET /api/views/:viewName/sse` — `view.changed`, live only (`routes/views.ts`).
An event's `id` there is the `seq` of the stored event that caused it, so it
repeats across the views one change touches; it is not a replay cursor.
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:1054`, with a `/sse` alias at `:1055`).
@@ -178,15 +178,58 @@ the destination's orchestrator when one is derivable, otherwise to the
destination itself, and that seat may be dead. Overdue claims go to their
destination. Repeated detections reuse the existing finding without changing
its route; when the source condition resolves, the sweep closes its own finding.
Previously, falling out of a reporting query could itself close a delivery or
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
an unreadable human registry or a larger reporting-age setting could therefore
look like successful recovery. These findings now require source evidence:
closure, pickup or parking of the work, a verified/accepted wake, or a posted
notification for the current human recipient and episode, as appropriate to the
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
legacy human receipts do not prove resolution. Uncertainty keeps an existing
finding open without itself sending another wake or creating another finding.
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
does not prove pickup.
A legacy human notice with no owner-notification history can still resolve from
the existing item-scoped posted ledger when its recipient is confirmed human.
This covers older pending asks and parks without inventing an episode that was
never recorded. A registered alias needs a readable registry to establish its
human recipient; reserved human references remain known during an outage.
Legacy receipts carry no recipient key, so a destination reroute requires a new
matching notification episode. A legacy park is accepted only when no earlier
notice or reroute predates it, and later history contains only receipts or
asking-agent reroutes within that park. A re-park, an exit from the park or an
ambiguous append cannot establish the same recipient without owner history;
the finding waits for a new matching episode or other source resolution.
Rerouting only the asking agent within an otherwise unchanged park preserves
its human recipient (`blockedOn`).
Modern notifications still require their current episode, and an unrelated
legacy human receipt cannot resolve an agent wake.
A known hold still leaves recovery with the existing mechanism: an ask on
Steering without an alert, a wake held by the seat typing guard, or a live
quiet-release job. Its closure note names that disposition rather than claiming
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
returning a row to `pending` while retaining its claim no longer hides a later
stall or closes its existing stalled-claim finding. A retained failed wake cannot
override that pickup evidence. The agent's claim still does not establish delivery
of a separate notification to a person.
That automatic closure records `stuck-sweep@system` as the actor in both the
transition and the `queue.updated` audit event. Previously it credited the
finding's source agent with the detector's action. The finding's source,
transition and the `queue.updated` audit event, with the source evidence in the
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
with the detector's action. The finding's source,
destination and claim remain its existing ownership records; the underlying work
is not reassigned, and existing transition history is not rewritten.
`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed
findings close once, their transition and event name the sweep, and ownership
stays intact. The posted-human-notice case also checks the actor while leaving
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
claims, current human-notification episodes and registry outages with migrated
SQLite, including positive recovery controls and no repeated send on an
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
pending and claimed findings close once, name the sweep in their transition and
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
@@ -418,6 +461,23 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
A finished item's result file is its `evidenceRef`, read through the daemon's
shared roots with `rig_artifact_read` (MCP) or `rig queue artifact <qitemId>
--json` (CLI). For a code result, the assignee writes a JSON file of kind
`openrig.code-result/v1`, with `repo` (an absolute path or a URL), `base` and
`revision` (full commit ids, SHA-1 or SHA-256), and optionally `paths`, `branch`
and `fetch: {remote, ref}`. Both reads then add `codeResult`, the exact pair a
client can diff. A manifest whose base or revision isn't a full commit id gets
`codeResultIssue` instead. `branch` and `fetch` are hints for finding the commits,
never the revision. Other files read as before.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
@@ -73,10 +73,26 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
first three; `not_attempted` is set when pre-restore validation fails
(`:281`).
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
**7** optional fields, kept optional so older snapshots still load
**8** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
Periodic snapshots retain a down agent's whole authoritative resume-source
session in `periodicResumeSourcesByNode`, without marking it running. A named
single-agent launch using automatic periodic selection can resume that exact
carried source; an explicit snapshot or whole-rig restore still asks about an
absent agent with resumable history. Tokenless and non-resume-policy agents
keep their existing fresh path. Only the node's newest session with a token
and `resume_if_possible` policy is carried; a current fresh-successor relation
must agree. Named launch rechecks the live newest session, resume policy and
fresh-successor relation under its lifecycle lease, before changing any seat
state; a source retired after capture stops and asks without launching.
Idle-shell carry advice uses automatic selection again, not an
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
restore; explicit reviewed-history selection keeps its existing checks.
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
if the earlier-source lookup fails: capture still records current crash
insurance and logs that the resume sources were unavailable.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
@@ -277,7 +293,7 @@ cited here by number with the code that carries them):
for one seat. `resume` captures an eligibility-checked `auto-rehydrate`
snapshot when none exists and goes through `launchSingleNode`.
- **Seat handover** — `rig seat handover <seat> --source
fresh|rebuild|fork:<id>|discovered:<id> --reason <reason> [--dry-run]`
fresh|rebuild|fork:<seat>|discovered:<id> --reason <reason> [--dry-run]`
(the reason is required, dry run included; without it the command exits 2
before contacting the daemon)
(`packages/cli/src/commands/seat.ts:364`) calls
@@ -389,6 +405,20 @@ daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
@@ -462,6 +462,40 @@ agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
### Current-generation records for `rig walk`
`rig walk` checks a delivered piece against the current conversation through
`GET /api/sessions/:sessionName/generation-record` (`routes/sessions.ts`).
Without `sinceBytes`, the route returns record identity and size; with it, the
route also reads a bounded suffix from the same open file. This terminal-class
endpoint uses the terminal bearer gate.
For Codex, the current occupant must have a verified identity for its bound
pane. The route compares tmux's current root PID with the identity's
`observedPanePid`, falling back to `observedPid` for older verdicts that only
recorded one PID. Previously it always compared the root with the native Codex
PID, refusing a verified Codex running beneath a shell or launcher. Recording
both PIDs lets that wrapper pass without accepting a changed pane root or
changing the identity schema.
For dual-PID verdicts, the selected thread must also be the thread resolved for
`observedPid`. All candidate threads still participate in the ambiguity check;
another process's resolvable thread cannot stand in for a missing or
unresolvable verified native process. This reuses the same process observation
and thread lookups, without an additional census.
The existing thread lookup still requires a Codex descendant with one
resolvable thread, a thread-table record path and a matching rollout header.
The route rechecks occupant generation, pane binding and root PID after the
thread lookup. These are bounded observations, not an atomic process snapshot.
An unresolved identity still returns 409; `rig walk` reports consumption as
unverified instead of claiming it read the record. Route regressions in
`packages/daemon/test/walk-consumption-primitives.test.ts` cover shell and
launcher wrappers, legacy verdicts, root changes, replaced or unresolved native
processes, and changes during thread resolution with synthetic process
observations and actual SQLite/JSONL
fixtures; they do not exercise a live Codex conversation.
## 5. Chat flow
1. `rig chatroom send <rig> "message"` → `POST
@@ -84,6 +84,15 @@ Diagnostic-only rows, moved sources, and versions retained for unfinished
work have separate paths in `readThrough`, `writeDiagnostic`, and
`isPinnedByUnfinishedWork`.
Only instantiate (and `rig workflow run`, which posts to the same route) and
the workflows-folder scan register specs through `readThrough`.
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
checks, in memory, with no `workflow_specs` write. A spec that was only
validated from outside the workflows folder is not listed by
`rig workflow specs` and is not found by its bare name; its path still
instantiates (#2726).
## Durable state
The migration files record the storage evolution; they are not a count of
+109 -22
View File
@@ -9,23 +9,23 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: 71b9c2c072ac5d27a23fb9fb404faa7033cb7e42
last-updated: 2026-10-24
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
last-updated: 2026-10-26
---
# CLI Reference — Registered Commands and Options
Verified against source commit `71b9c2c072ac5d27a23fb9fb404faa7033cb7e42`.
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 **90 top-level registrations**, **401 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,274 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 counts for that source snapshot, 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
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
@@ -208,7 +208,23 @@ the sections that were not read are `null`.
| `rig gateway human show <entityId>` | — | `--json` |
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human remove <entityId>` | — | `--force` |
| `rig gateway channel` | — | — |
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
| `rig gateway channel remove <id>` | — | `--force` |
| `rig gateway org` | — | — |
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway group` | — | — |
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
| `rig gateway default [entityId]` | — | `--clear` |
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
### parked
@@ -334,17 +350,47 @@ the sections that were not read are `null`.
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
| `rig host select <id>` | — | `--json` |
| `rig host rename <name>` | — | `--json` |
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
| `rig host remove <id>` | — | `--json` |
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
`laptop`, run this on the local instance to include its attention items:
```sh
rig config set feed.subscriptions.laptop.enabled true
```
The key uses the registry id, including any dots, and defaults to `false`. Registration
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
than an invalid command. `--json` keeps its single JSON result without these hints.
The host CLI tests execute the advertised command against an isolated config and cover
registration, reserved names and JSON output; pairing responses are simulated, so they
do not establish connectivity to a live peer.
### ps
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
- `--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.
### roster
@@ -360,7 +406,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>` |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
| `rig mcp token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
@@ -372,9 +418,21 @@ the sections that were not read are `null`.
|---|---|---|
| `rig acp` | — | — |
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
### exec
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig exec` | — | — |
| `rig exec harnesses` | — | `--json` |
| `rig exec run <qitemId>` | — | `--harness <name>` **required**<br>`--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--wait`<br>`--plan`<br>`--again`<br>`--poll-seconds <n>` |
| `rig exec status <runIdOrQitemId>` | — | `--json` |
| `rig exec cancel <runId>` | — | — |
| `rig exec connector <runId>` (hidden) | — | — |
### agent
| Invocation | Aliases | Declared options |
@@ -420,6 +478,11 @@ the sections that were not read are `null`.
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
Stream intake: the `stream.intake` setting (default `queue.worker`, or a canonical `<seat>@<rig>`) receives each emitted item.
It opens an intake row for that seat, which wakes it, or adds a note to that seat's intake row still pending; a `critical`
or interrupting item makes the row urgent. `rig config set stream.intake off` keeps emits passive. A runner's
default-permit record (tag `runner-permission-permit`) opens no row.
### queue
| Invocation | Aliases | Declared options |
@@ -428,16 +491,17 @@ the sections that were not read are `null`.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json`<br>`--host <id>` |
| `rig queue artifact <qitemId>` | — | `--root <name>`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full`<br>`--host <id>` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json`<br>`--host <id>` |
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue inbox-drop <destinationSession>` | — | `--sender <session>`<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <inboxId>`<br>`--json` |
@@ -452,7 +516,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig slack` | — | — |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reactions <mode>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack status` | — | `--json` |
| `rig slack manifest` | — | `--url`<br>`--json` |
| `rig slack verify` | — | `--json` |
@@ -462,6 +526,7 @@ the sections that were not read are `null`.
| `rig slack channel-map remove <match>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack enable` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack disable` | — | `--reason <reason>` **required**<br>`--actor <actor>` |
| `rig slack post-open-asks` | — | `--reason <reason>`<br>`--wait <seconds>`<br>`--actor <actor>`<br>`--json` |
| `rig slack outbound` | — | `--json` |
| `rig slack inbound` | — | — |
@@ -507,6 +572,7 @@ the sections that were not read are `null`.
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
| `rig terminal attach <session>` | — | `--read-only` |
See [named terminal sessions](../reference/terminal-sessions.md) to select a
Herdr session per request, including session-bound previews and window reuse.
@@ -539,9 +605,10 @@ Herdr session per request, including session-bound previews and window reuse.
| `rig workflow specs` | — | `--json` |
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
| `rig workflow show <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--full`<br>`--json` |
| `rig workflow continue <instanceId>` | — | `--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow unregister [spec]` | — | `--prune-missing`<br>`--json` |
| `rig workflow watch <instanceId>` | — | `--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
@@ -552,7 +619,7 @@ Herdr session per request, including session-bound previews and window reuse.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--host <id>`<br>`--json` |
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--readable`<br>`--size <WxH>`<br>`--host <id>`<br>`--json` |
### broadcast
@@ -594,7 +661,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
@@ -663,6 +730,24 @@ Only Claude and Codex tenures can be woken.
| `rig community-harness` | — | — |
| `rig community-harness list` | — | `--json` |
### environment
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig environment` | — | — |
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
### fork
| Invocation | Aliases | Declared options |
@@ -760,8 +845,8 @@ Only Claude and Codex tenures can be woken.
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--reason <text>`<br>`--model <id>`<br>`--codex-config-profile <name>`<br>`--dry-run`<br>`--json` |
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
@@ -939,6 +1024,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig seat` | — | — |
| `rig seat history` | — | `--rig <id>` **required**<br>`--node-id <id>` **required**<br>`--snapshot-id <id>`<br>`--history-session-id <id>`<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
@@ -947,11 +1033,12 @@ for input and receipt examples, rollback, and snapshot behavior.
| `rig seat switch-client <seat>` | — | `--to-window <n>`<br>`--client <id>`<br>`--json` |
| `rig seat clear-attention <session>` | — | `--reason <text>`<br>`--json` |
| `rig seat set-terminal-policy <seat>` | — | `--preset <preset>` **required**<br>`--helper <addresses...>`<br>`--rig-default`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json`<br>`--peer-compaction <decision>`<br>`--peer-model <decision>`<br>`--self-compaction <decision>`<br>`--self-model <decision>`<br>`--openrig-compaction <decision>`<br>`--openrig-model <decision>` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-permissions-to <seat>` | — | `--to <person>`<br>`--clear`<br>`--deny`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat effort <seat>` | — | `--json` |
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-cwd <seat>` | — | `--cwd <directory>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>`<br>`--plan`<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat restart <seat>` | — | `--keep-conversation` **required**<br>`--reason <text>`<br>`--plan`<br>`--verify`<br>`--expect-account <text>`<br>`--operator <address>`<br>`--host <id>`<br>`--json` |
+57 -15
View File
@@ -88,6 +88,11 @@ are not yet runnable checks.
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
@@ -104,7 +109,17 @@ credentials are supplied.
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
@@ -143,7 +158,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `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`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
@@ -153,6 +168,28 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
### Host mode: while you're writing a scenario
The tmux server selection regression also uses these scaffold and lifecycle helpers
in `packages/daemon/test/tmux-server-isolation-daemon.test.ts`. It runs a real stub
seat through config changes, daemon restarts, capture, self-attachment and cleanup,
while native socket observations verify that a personal namesake survives. The
test also removes the effective-server record and verifies that `destroy --all`
leaves the daemon, database and both servers' sessions intact before restoring
the record and checking normal cleanup. The
YAML grammar has no config-write or socket-inventory surface, so these assertions
live in the Vitest integration test. It does not exercise a real provider model,
SSH connection or graphical terminal window.
`packages/daemon/test/tmux-server-two-instances.test.ts` adds two real daemons
sharing a scratch OS home and inherited personal tmux server, with separate
OpenRig homes, databases and ports. Both enable isolation and control mode. It
checks same-named seats through inventory, capture and verified writes, observes
the marked control session on each selected socket, then stops one instance and
checks that the other instance's pane identity, control session and delivery still
work. The personal namesake remains untouched. This test requires a tmux version
that supports control mode and the built CLI and daemon. Like CI's package suites,
it should run with scratch homes and outbound network restricted to localhost;
scripted seats prove terminal routing and rendered delivery, not model receipt.
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
@@ -175,10 +212,12 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
containers.
each run three times as healthy, then lost-baton, then healthy (six containers); the
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
fifteen fresh containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
@@ -188,10 +227,13 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
@@ -258,7 +300,7 @@ means:
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `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. |
| 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. |
@@ -299,9 +341,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | `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
@@ -498,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. |
+5 -1
View File
@@ -181,7 +181,11 @@ UI event consumption is centralized through one shared `/api/events`
connection (`lib/topology-events.ts`) so related surfaces avoid duplicate
SSE connections. Current consumers: For You feed hydration, topology
activity rings + hot-potato movement, the activity feed/system surfaces,
and rig event surfaces. `useGlobalEvents` reports whether that connection
and rig event surfaces. That connection carries stored events only:
`view.changed` is delivered on the live view streams (`/api/views/sse`) and
is not stored, so it no longer appears in the activity feed, as a For You
observation card or in the telemetry export (entries stored by older
versions still replay until an events retention, a separate change, removes them). `useGlobalEvents` reports whether that connection
is up and refetches review, slice and mission data on reconnect and on
`proof.*` events. Reduced-motion preference is honoured for pulse/packet
animation. (Daemon SSE surfaces: `/api/events`, `/api/stream/watch`,
+16 -4
View File
@@ -55,8 +55,17 @@ transitions before trying again. Do not silently send the task a second time.
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
returns an echo artifact. Its README explains startup; it does not implement the input-required and cancellation
controls described later. Use a peer that supports those operations when testing them.
returns an echo artifact. Fetch that revision:
```sh
git clone https://github.com/a2aproject/a2a-samples.git
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
```
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
The sample does not implement the input-required and cancellation controls described later. Use a peer that
supports those operations when testing them.
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
@@ -128,7 +137,8 @@ async def main():
asyncio.run(main())
```
Run it from your seat, replacing the row and peer URL:
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
```sh
export A2A_URL=http://127.0.0.1:9999
@@ -260,7 +270,7 @@ export A2A_SEAT=<worker-session-from-rig-ps>
# Client terminal, in the folder with the example scripts and venv:
unset A2A_ROW A2A_TASK A2A_CONTEXT
export A2A_URL=http://127.0.0.1:19092
export A2A_TEXT='Return the words hello from the receiving seat.'
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
.venv/bin/python a2a_client.py
```
@@ -277,6 +287,8 @@ rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
the worker must be watching its queue; merely starting the front does not start or wake an agent.
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
+176 -2
View File
@@ -13,7 +13,8 @@ The row's notes get, in order:
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output;
agent's output and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
none);
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
@@ -62,6 +63,78 @@ shared one running; stop your own with its own command.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Claude Code and Codex, through their ACP adapters
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
```sh
npm install --prefix <dir> @agentclientprotocol/claude-agent-acp@0.101.0 @agentclientprotocol/codex-acp@2.5.0
export PATH="<dir>/node_modules/.bin:$PATH"
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
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
@@ -70,8 +143,109 @@ shared one running; stop your own with its own command.
- **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.
- 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
(`rig daemon start`), unless `OPENRIG_URL` names another.
The team's questions reach you only as a registered person: an `<id>@external` address needs
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
so at your first prompt, and your work is still sent.
**In Zed**, add the agent to `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
}
```
Other apps take the same command and arguments wherever they configure a custom ACP agent.
**Limits:**
- One receiver per agent command (or the `acp.to` setting); the app has no team picker.
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
- An app that shows a permission request's options only as fixed buttons, such as Approve and Decline, and doesn't
take forms, can't show the choices: answer those questions in the TUI or Slack.
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
- Sessions are found among the 2,000 newest rows you've sent, and a reopened session's questions among the 2,000 newest
sent to you, so a caller with a lot of traffic, such as a busy seat, can't list or fully reopen its oldest sessions.
+72 -7
View File
@@ -229,9 +229,10 @@ This gives access to the shared pool: skills such as `systematic-debugging`, `ve
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
`refocus-channel.md`); a seat that didn't select it gets neither.
`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.
---
@@ -287,7 +288,11 @@ Each array contains resource IDs. These can be:
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's 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.
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
@@ -370,10 +375,10 @@ version.
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `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. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `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.
@@ -419,6 +424,16 @@ 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), except a plugin's
@@ -427,6 +442,55 @@ overwritten, and the rest of the fragment still applies.
- 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
@@ -440,7 +504,7 @@ guidance:
|-------|------|----------|---------|-------------|
| `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 | — | 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`. |
| `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. |
---
@@ -482,6 +546,7 @@ restore behavior, and the distinction between readiness and verified proof.
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
+189 -4
View File
@@ -63,6 +63,33 @@ delivery has been attempted, it cannot be replayed by `continue`, including
after a daemon restart: inspect `rig seat status <seat>` and the native pane
before deciding how to recover an uncertain delivery.
## After a restored seat's prompt
A restore that stops at a prompt, such as Claude's resume selection, records the
seat's restore outcome as `attention_required`. `rig seat continue` doesn't change
that record: it only delivers pending startup context. While the record stands, it
alone keeps the seat's lifecycle at `attention_required`, even with startup `ready`:
`rig ps --nodes` shows LIFECYCLE `att`, `needsAttention` is true, and the seat counts
in the rig's `attentionCount` and the TUI's attention totals.
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
the seat's activity next turns to running or idle, or when the whole restore completes
after the seat already reads running or idle, the daemon checks the live seat: its tmux
session is alive, and the pane runs the agent resuming the conversation the seat
records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When it fails, the restore outcome
stays and the seat still needs attention, which is true when the pane runs another
conversation. A check that started before a newer restore of the seat doesn't settle
the newer outcome.
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
command says which check failed, and `--reason` doesn't bypass it. If
the seat records the wrong conversation and you know the right one, set it with
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again. After a restore of only some seats, or a community
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
outcome instead.
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
@@ -214,7 +241,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `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 |
@@ -244,6 +271,15 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
@@ -355,10 +391,46 @@ declaration isn't changed either; update it separately if a future recreation sh
Each startup file and action specifies when it applies:
- `fresh_start` — delivered when a new conversation starts: the first launch, `rig seat launch --fresh`,
`rig seat continue`, and seats restored with `--fresh`
- `restore` — delivered when a seat is restored, including a restore whose resume falls back to a fresh conversation
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: 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`, `codex` and `claude-code` 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 or Claude executable (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 when wrapper support is unavailable; 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
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
---
@@ -398,6 +470,11 @@ Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof"
`idempotent` is required, and an action that isn't idempotent must not list `restore` in `applies_on`. Both phases
run after the harness is ready: `after_files` (the default) after the post-launch files, then `after_ready`.
**Don't rename a managed Claude seat.** OpenRig starts every Claude seat with `--name <session>`, on a fresh start and on
every resume, and its own reads of the seat use that name: context usage and managed compaction read the status-line
record Claude saves under it, and the input box is recognized by the name on its border. A startup `/rename` to another
name breaks those reads until the next resume puts the session name back.
### Current Support Matrix
| Capability | Status | Notes |
@@ -487,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:**
@@ -517,10 +646,17 @@ remain shared.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- 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
@@ -627,6 +763,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## 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
@@ -696,6 +855,32 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
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
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
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.
+58 -3
View File
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`).
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -41,6 +44,13 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
Both herdr and omnigent converged on this exclusion, with warning comments on the
collision in both codebases.
The legacy `agentActivity` field distinguishes a typed provider interruption from
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
hook. A permission prompt can independently supply human-input evidence. See
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
for the separate provider signal and queue timer.
### Per-seat attention in the node inventory
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
@@ -52,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -95,7 +120,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
@@ -109,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
@@ -132,6 +160,26 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
@@ -158,6 +206,13 @@ LIFECYCLE); see the
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
An `attention_required` outcome, or `failed` with a running session, makes the seat's
lifecycle `attention_required`, whatever its startup status, so it raises `needsAttention`.
After a full restore, the daemon reconciles it to `operator_recovered` when the seat's
activity next turns to running or idle (or reads so when the restore completes) and the
live seat passes its check, and `rig seat clear-attention <seat>` runs that check on
demand; `rig seat continue` doesn't change it
([startup guide](agent-startup-guide.md#after-a-restored-seats-prompt)).
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
+74
View File
@@ -0,0 +1,74 @@
# 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. |
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the [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.
+12 -1
View File
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
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.
+42
View File
@@ -67,3 +67,45 @@ directory: its timestamp should advance and its `session_id` should match the
current Claude session. A visible custom status line alone does not verify
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
collector errors; remove its stderr redirection temporarily when diagnosing it.
## Claude usage-limit detection
The status line supplies subscription usage windows and their reset timestamps.
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
provider interruption even when no status line window is available. This path
requires the updated OpenRig hook configuration and the current seat's runtime
and occupant generation. Unknown identity or an expired event cannot trigger a
usage-limit pause; a hook with a newer source timestamp supersedes the event,
regardless of arrival order. The event expires after five minutes in any case.
A helper Claude started from a seat's shell inherits that seat's environment,
so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
existing `explicit_unknown` host diagnosis.
Pending work handled by the wake ladder uses the existing local Claude pool
(the one-account-per-host assumption), with separate pools for seats that
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
source, provider usage remains unknown.
@@ -11,3 +11,4 @@ launch_result: true
resume: true
startup_assisted: false
first_input: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -34,6 +34,12 @@ chat_store() {
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
set -- --model "$OPENRIG_MODEL" "$@"
fi
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
@@ -0,0 +1,96 @@
# An example runner for DeepSeek Harness (dsh)
`openrig-dsh-runner.mjs` is a small runner for the draft [harness and runner contract](../../harness-runner-contract.md).
It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and does the runner's part of each contract
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
It has no dependencies, and it isn't installed by OpenRig. Copy it with [`dsh.yaml`](dsh.yaml) and maintain both as your
own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh runner by its own maintainer.
## Set it up
1. **Install dsh and Node.** Install Node 22 or later, then `npm i -g @deepseek-ai/[email protected]`. This is the version
it was written for, and it has been checked only against a stand-in agent.
2. **Install the runner and preset.** Make the runner executable and put it on the seat's `PATH`. Copy `dsh.yaml` to
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
a preset `command` wrapper.
The runner's own defaults for dsh:
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
## Permissions
The runner submits each request to OpenRig, which asks one person:
- the default person;
- or the person chosen for the seat with `rig seat set-permissions-to <seat> --to <person> --reason "…"`.
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
**`allow_always` creates a standing grant in dsh.** OpenRig doesn't record or revoke it, which is why the runner prefers
`allow_once`. Where dsh keeps the grant, and for how long, isn't checked.
**The runner refuses** only when the seat's operator opted in to refusal, with `rig seat set-permissions-to <seat>
--deny`, or when dsh offered no allow option. A refused request is shown in the pane, and the seat reads needs input
until the next prompt.
**The runner answers cancelled, holding nothing,** when the person says no and dsh offered no reject option, or when
OpenRig says the request isn't the seat's current occupant's (for example, after a relaunch).
## Before you declare resume
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
- **Only dsh's own not-found error (`-32002`) counts as missing.** Any other failure leaves the resume unconfirmed.
- **When dsh advertises `session/list`** (it does), the runner first asks the list, following its pages. A conversation
the list names but that won't reopen stays unconfirmed, even on a not-found.
- **An unlisted conversation is still reopened.** dsh's list can leave out a conversation another running dsh holds
unsaved, so it can't show a conversation is gone.
**Limit:** dsh answers `-32602`, not `-32002`, for a conversation it doesn't have, so the runner can't report a missing
dsh conversation as missing. It reads unconfirmed.
Add `resume: true` after a real dsh has reopened a conversation and recalled what was said in it.
## Check it without a model
Point dsh's `DEEPSEEK_BASE_URL` at a local server that speaks DeepSeek's Anthropic-compatible Messages API. Then run the
contract's real check:
- a seat reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled.
`packages/daemon/test/community-harness-dsh-example.test.ts` runs the same cases against a stand-in agent.
@@ -0,0 +1,20 @@
# 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
- no guidance, skills or startup files are placed; dsh reads its own AGENTS.md and skill folders
- when the pane closes, the runner cancels a running turn and stops dsh; anything dsh's tools started may keep running
- fork unsupported
@@ -0,0 +1,626 @@
#!/usr/bin/env node
// Example OpenRig runner for DeepSeek Harness (dsh), not installed by OpenRig. Checked against a stand-in agent only;
// written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh), ACP protocol 1 and Node 22 or later. No dependencies.
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
// own.
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
const WORK_LIVENESS_SECONDS = 30;
const WORK_BEAT_MS = 10_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
const READ_TRIES = 3; // reads in a row that get no answer before the runner permits
// Refusals that say the request certainly isn't the seat's current occupant's live one: cancel it, and hold nothing.
const NOT_LIVE = ["stale_generation", "activity_grant_wrong_seat", "activity_grant_stale"];
// A read's states that end the request, answered cancelled (an `answered` that reaches there names an option dsh didn't
// offer). Any other state, like an unlisted status, permits.
const ENDED = ["refused", "withdrawn", "stale", "answered"];
const CANCELLED = { outcome: { outcome: "cancelled" } };
const ACP_RESOURCE_NOT_FOUND = -32002;
// The most pages of dsh's session list read before a resume; a longer list decides nothing.
const LIST_PAGES = 50;
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
/** Turns terminal bytes into submitted blocks. Everything up to an Enter outside a paste is one block, line breaks
* included; a block that is exactly /abort, or a Ctrl-C byte, is the only control. */
export class InputFramer {
#buffer = "";
#pasting = false;
#partial = "";
push(chunk) {
const events = [];
const data = this.#partial + chunk;
this.#partial = "";
let i = 0;
while (i < data.length) {
if (data[i] === "\x1b") {
const rest = data.slice(i);
if (rest.startsWith(PASTE_START) || rest.startsWith(PASTE_END)) {
this.#pasting = rest.startsWith(PASTE_START);
i += PASTE_START.length;
continue;
}
if (PASTE_START.startsWith(rest) || PASTE_END.startsWith(rest)) { this.#partial = rest; break; }
if (!this.#pasting) { i += escapeLength(rest); continue; } // arrow keys and the like
}
const ch = data[i];
i += 1;
if (ch === "\x03") { this.#buffer = ""; this.#pasting = false; events.push({ type: "abort" }); continue; }
if (this.#pasting) {
if (ch === "\r") { if (data[i] === "\n") i += 1; this.#buffer += "\n"; } else this.#buffer += ch;
continue;
}
if (ch === "\r" || ch === "\n") {
const text = this.#buffer;
this.#buffer = "";
if (text.trim() === "/abort") events.push({ type: "abort" });
else if (text.trim()) events.push({ type: "block", text });
continue;
}
if (ch === "\x7f" || ch === "\b") {
if (this.#buffer) { this.#buffer = this.#buffer.slice(0, -1); events.push({ type: "erase" }); }
continue;
}
if (ch >= " ") { this.#buffer += ch; events.push({ type: "echo", text: ch }); }
}
return events;
}
}
function escapeLength(rest) {
if (rest[1] === "[") {
let j = 2;
while (j < rest.length && !(rest.charCodeAt(j) >= 0x40 && rest.charCodeAt(j) <= 0x7e)) j += 1;
return Math.min(j + 1, rest.length);
}
return rest[1] === "O" ? Math.min(3, rest.length) : 1;
}
/** JSON-RPC 2.0 with the agent, one message per line on its stdio. */
class AgentPeer {
#child;
#next = 1;
#waiting = new Map();
#requests = new Map();
#notifications = new Map();
constructor(child, onNoise) {
this.#child = child;
createInterface({ input: child.stdout }).on("line", (line) => {
let message;
try { message = JSON.parse(line); } catch { message = null; }
if (!message || typeof message !== "object") { if (line.trim()) onNoise(line); return; }
if (message.method === undefined) {
const waiter = this.#waiting.get(message.id);
if (!waiter) return;
this.#waiting.delete(message.id);
if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? "error"), { code: message.error.code }));
else waiter.resolve(message.result);
return;
}
if (message.id === undefined) { this.#notifications.get(message.method)?.(message.params ?? {}); return; }
const handler = this.#requests.get(message.method);
if (!handler) { this.#write({ id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } }); return; }
Promise.resolve().then(() => handler(message.params ?? {}, message.id)).then(
(result) => this.#write({ id: message.id, result }),
(error) => this.#write({ id: message.id, error: { code: error.code ?? -32603, message: error.message } }));
});
}
request(method, params) {
const id = this.#next++;
return new Promise((resolve, reject) => { this.#waiting.set(id, { resolve, reject }); this.#write({ id, method, params }); });
}
notify(method, params) { this.#write({ method, params }); }
onRequest(method, handler) { this.#requests.set(method, handler); }
onNotification(method, handler) { this.#notifications.set(method, handler); }
failAll(reason) {
for (const waiter of this.#waiting.values()) waiter.reject(new Error(reason));
this.#waiting.clear();
}
#write(message) {
if (this.#child.stdin.writable) this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
}
}
const openrigHome = () => process.env.OPENRIG_HOME || join(homedir(), ".openrig");
/** The daemon's base URL and the seat's token: from the environment, else from activity-endpoint.json. */
function openrigEndpoint() {
const env = process.env;
let base = env.OPENRIG_URL || (env.OPENRIG_PORT ? `http://${env.OPENRIG_HOST || "127.0.0.1"}:${env.OPENRIG_PORT}` : "");
let token = env.OPENRIG_ACTIVITY_HOOK_TOKEN || "";
if (!base || !token) {
try {
const file = JSON.parse(readFileSync(join(openrigHome(), "activity-endpoint.json"), "utf8"));
base ||= file.baseUrl ?? "";
token ||= file.token ?? "";
} catch { /* no daemon has written one yet */ }
}
return base && token ? { base: base.replace(/\/+$/, ""), token } : null;
}
async function callOpenRig(method, path, body, timeoutMs) {
const endpoint = openrigEndpoint();
if (!endpoint) return { status: 0, body: null };
try {
const response = await fetch(`${endpoint.base}${path}`, {
method, signal: AbortSignal.timeout(timeoutMs),
headers: { authorization: `Bearer ${endpoint.token}`, ...(body ? { "content-type": "application/json" } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
let parsed = null;
try { parsed = await response.json(); } catch { /* not JSON */ }
return { status: response.status, body: parsed };
} catch {
return { status: 0, body: null };
}
}
/** Activity posts go one at a time, in order, and never hold up the agent. */
class Activity {
#sequence = 0;
#chain = Promise.resolve();
constructor(identity) { this.identity = identity; }
post(hookEvent, subtype, extra = {}) {
const body = { ...this.identity, sequence: ++this.#sequence, occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra };
this.#chain = this.#chain.then(() => callOpenRig("POST", "/api/activity/hooks", body, POST_TIMEOUT_MS)).then(() => {}, () => {});
}
flushed() { return this.#chain; }
}
const UPLOADS_OFF = [
"# Written by openrig-dsh-runner for this seat; safe to edit, and an existing file is left alone.",
"# Don't attach this seat's session log, or its plugin package names and versions, to DeepSeek API requests.",
"- id: session-log-deepseek",
" config:",
" enabled: false",
"- id: plugin-package-inventory-deepseek",
" config:",
" enabled: false",
"",
].join("\n");
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
function seatHome() {
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
if (prepared) return prepared;
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
const home = join(openrigHome(), "state", "dsh", seat, "home");
mkdirSync(home, { recursive: true, mode: 0o700 });
const patch = join(home, "cordis.patch.yml");
if (!existsSync(patch)) writeFileSync(patch, UPLOADS_OFF, { mode: 0o600 });
return home;
}
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
function cliVersion(bin, env) {
try {
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
} catch {
return null;
}
}
function writeLaunchRecord(fields) {
const path = process.env.OPENRIG_LAUNCH_RESULT;
if (!path) return;
const record = { format: 1, attempt: process.env.OPENRIG_LAUNCH_ATTEMPT, harness: NAME, ...fields };
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
renameSync(temp, path);
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const say = (line) => process.stdout.write(`${line}\n`);
const firstLine = (text) => {
const lines = text.split("\n");
return lines.length > 1 ? `${lines[0]} … (+${lines.length - 1} lines)` : lines[0];
};
async function main() {
const env = process.env;
const cwd = process.cwd();
let firstInput = null;
if (env.OPENRIG_FIRST_INPUT) {
try { firstInput = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8"); } catch (error) {
console.error(`openrig-dsh-runner: the first startup turn couldn't be read (${error.message}); not starting`);
process.exit(2);
}
}
const home = seatHome();
const activity = new Activity({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID,
runtime: NAME, generation: env.OPENRIG_OCCUPANT_GENERATION });
const agentEnv = { ...env, DSH_HOME: home };
delete agentEnv.OPENRIG_ACTIVITY_HOOK_TOKEN; // dsh's shell tool inherits this environment
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
const version = cliVersion(bin, agentEnv);
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let workBeat;
let tty = false;
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
const restoreTty = () => {
if (!tty) return;
tty = false;
process.stdout.write("\x1b[?2004l");
try { process.stdin.setRawMode(false); } catch { /* already closed */ }
};
const finish = async (code) => {
clearInterval(heartbeat);
clearInterval(workBeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
};
const fatal = async (message, code) => {
stopping = true;
say(`[openrig-dsh] ${message}`);
if (!agentExited) {
child.kill("SIGTERM");
await Promise.race([new Promise((done) => child.once("exit", done)), sleep(3_000)]);
}
await finish(code);
};
const postIdle = (subtype) => activity.post("Stop", subtype, { livenessSeconds: LIVENESS_SECONDS });
const withdraw = (askId, reason) => track(callOpenRig("POST", `/api/activity/permission-requests/${encodeURIComponent(askId)}/withdraw`, { reason }, POST_TIMEOUT_MS));
child.once("error", (error) => { agentExited = true; void fatal(`dsh couldn't be started (${error.message}); is it on PATH?`, 1); });
child.on("exit", (code, signal) => {
agentExited = true;
peer.failAll("dsh exited");
if (stopping) return;
for (const ask of state.asks) ask.end("exited");
say(`\n[openrig-dsh] dsh exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
activity.post("Stop", `${NAME}_exited`);
void finish(code === 0 ? 0 : 1);
});
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = working();
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
} else if (update.sessionUpdate === "tool_call") {
say(`\n[tool] ${update.title ?? "tool"} (${update.kind ?? "other"})`);
if (reporting) activity.post("PreToolUse", String(update.kind ?? "other"));
}
});
const refuse = (why) => {
state.holding = true;
state.refused = true; // the seat's own refusal holds needs input until the next prompt
say(`[permission] refused: ${why}. The seat reads needs input until the next prompt.`);
activity.post("Notification", "permission_prompt", { turnId: String(state.turn) });
return CANCELLED;
};
// The answered path: OpenRig opens one ask to the default person (or the seat's override, `rig seat
// set-permissions-to`), and the runner reads the answer back for this same live request. With no one to ask, OpenRig
// permits at once, and so does the runner when OpenRig can't answer. Only the seat's opt-in (`--deny`) refuses; a
// request OpenRig says isn't the current occupant's, or that a person said no to with no reject option, is cancelled.
const askOwner = async (requestId, toolCall, options) => {
const turn = state.turn;
const live = () => state.running && state.turn === turn && !state.cancelRequestedAt && !agentExited;
const submitted = await callOpenRig("POST", "/api/activity/permission-requests", {
sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, generation: env.OPENRIG_OCCUPANT_GENERATION,
nativeRef: state.sessionId, turnId: String(turn), requestId: String(requestId), toolCall,
options: options.map((option) => ({ optionId: option.optionId, name: option.name ?? option.optionId, kind: option.kind })),
occurredAt: new Date().toISOString(),
}, 10_000);
const askId = submitted.body?.ok ? submitted.body.askId : undefined;
if (!live()) {
if (askId) withdraw(askId, state.cancelRequestedAt ? "turn_cancelled" : agentExited ? "exited" : "turn_ended");
return CANCELLED;
}
const offered = (optionId) => options.some((option) => option.optionId === optionId);
// No one can be asked: OpenRig's default is permit, with the agent's own allow option.
if (submitted.body?.ok && submitted.body.state === "permitted" && offered(submitted.body.optionId)) {
say(`[permission] permitted: ${submitted.body.reason ?? "no one can be asked"} (${submitted.body.optionId})`);
return { outcome: { outcome: "selected", optionId: submitted.body.optionId } };
}
// When OpenRig can't answer, nobody can be asked either, so the same default applies. allow_once is preferred, so
// dsh doesn't remember a permission nobody chose.
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
const passOn = (said) => {
if (typeof said !== "string" || !said.trim()) return;
say(`[permission] the person also wrote: ${firstLine(said)}`);
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
say(`[permission] cancelled: OpenRig says this isn't the seat's current request (${submitted.body.code})`);
return CANCELLED;
}
return permit(submitted.status === 404 ? "this OpenRig has no answered path"
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
state.waiting += 1;
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
const resolved = () => { state.resolvedAsk = { turn, askId }; };
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
const ask = { end: (reason) => {
if (ended) return;
ended = true;
state.asks.delete(ask);
withdraw(askId, reason);
if (done) return;
done = true;
stopWaiting(); // the turn ended the person's ask
resolve(CANCELLED);
} };
state.asks.add(ask);
void (async () => {
let unanswered = 0;
while (!done) {
const read = await callOpenRig("GET", `/api/activity/permission-requests/${encodeURIComponent(askId)}?waitSeconds=${WAIT_SECONDS}`, null, (WAIT_SECONDS + 10) * 1_000);
if (done) return;
if (read.status === 0 && ++unanswered < READ_TRIES) { await sleep(2_000); continue; }
if (read.status === 200 && read.body?.state === "pending") { unanswered = 0; continue; }
done = true;
const answer = read.body ?? {};
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
stopWaiting();
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
resolved();
passOn(answer.said);
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
stopWaiting();
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
} else {
state.asks.delete(ask);
stopWaiting(); // a person said no, or the request is over
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
passOn(answer.said);
}
}
})();
});
};
peer.onRequest("session/request_permission", (params, requestId) => {
if (params.sessionId !== state.sessionId) throw Object.assign(new Error(`no turn of conversation ${params.sessionId} runs here`), { code: -32602 });
const toolCall = { title: String(params.toolCall?.title ?? "a tool call"), kind: String(params.toolCall?.kind ?? "other") };
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
});
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
// goes on. It goes out after the agent has its answer, and before the next beat.
const settle = () => {
const done = state.resolvedAsk;
if (!done || state.holding || state.asking > 0) return;
state.resolvedAsk = null;
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
};
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
state.cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId: state.sessionId });
say(`\n[openrig-dsh] cancel requested at ${state.cancelRequestedAt}; the turn ends when dsh says so`);
if (!state.refused) state.holding = false; // the seat's own refusal holds until the next accepted prompt
for (const ask of state.asks) ask.end("turn_cancelled");
};
const runTurn = async (text) => {
state.running = true;
state.turn += 1;
state.holding = false;
state.refused = false;
state.waiting = 0;
state.resolvedAsk = null;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
say(`\nyou ▸ ${firstLine(text)}`);
activity.post("UserPromptSubmit", "accepted", { turnId });
let stopReason;
try {
const result = await peer.request("session/prompt", { sessionId: state.sessionId, prompt: [{ type: "text", text }] });
stopReason = String(result?.stopReason ?? "unreported");
} catch (error) {
if (agentExited) return;
say(`\n[openrig-dsh] the turn failed: ${error.message}`);
activity.post("Notification", "runtime_error", { turnId });
stopReason = "error";
}
state.running = false;
for (const ask of state.asks) ask.end("turn_ended");
say(`\n── turn end (${stopReason}) ──`);
if (state.holding) say("[openrig-dsh] a permission request was refused, so the seat reads needs input until the next prompt");
else postIdle(stopReason);
pump();
};
const pump = () => {
if (state.running || agentExited || state.queue.length === 0) return;
void runTurn(state.queue.shift());
};
const submit = (text) => {
if (agentExited) return;
state.queue.push(text);
if (state.running) say(`[queued: ${state.queue.length}] this goes to dsh after the current turn`);
pump();
};
// Handshake, then open or reopen the conversation, then report it. Nothing is read as ready before this.
let init;
try {
init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig-dsh-runner", version: "1" } });
} catch (error) {
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
}
const capabilities = init?.agentCapabilities ?? {};
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
// cursor is an opaque string, possibly empty, passed back unchanged; an absent or null one ends the list.
const listsConversation = async (id) => {
let cursor;
for (let page = 0; page < LIST_PAGES; page += 1) {
let listing;
try { listing = await peer.request("session/list", cursor === undefined ? {} : { cursor }); } catch { return undefined; }
if (!Array.isArray(listing?.sessions) || listing.sessions.some((session) => typeof session?.sessionId !== "string")) return undefined;
if (listing.sessions.some((session) => session.sessionId === id)) return true;
if (listing.nextCursor === undefined || listing.nextCursor === null) return false;
if (typeof listing.nextCursor !== "string") return undefined;
cursor = listing.nextCursor;
}
return undefined;
};
if (resumeRef) {
// The method dsh advertises; with neither advertised, session/resume, the method dsh 0.2.1-rc.2 answered in its
// maintainer's checks. dsh 0.2.1-rc.2 answers -32602 both for a conversation it doesn't have and for one active
// elsewhere, so that error can't mean missing. Only dsh's own "not found" counts as missing, and not when dsh's list
// names the conversation. The list can't show a conversation is gone: one another running dsh holds unsaved may be
// left out, so an unlisted conversation is still reopened. Anything else leaves the resume unconfirmed.
const method = capabilities.sessionCapabilities?.resume ? "session/resume" : capabilities.loadSession ? "session/load" : "session/resume";
const listed = capabilities.sessionCapabilities?.list ? await listsConversation(resumeRef) === true : false;
try {
await peer.request(method, { sessionId: resumeRef, cwd, mcpServers: [] });
} catch (error) {
if (error.code === ACP_RESOURCE_NOT_FOUND && !listed) {
writeLaunchRecord({ error: "missing", producer });
return fatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`, 3);
}
return fatal(`dsh couldn't reopen conversation ${resumeRef} (${method}: ${error.message}); the resume is unconfirmed and no new conversation was started`, 4);
}
state.sessionId = resumeRef;
} else {
try {
state.sessionId = (await peer.request("session/new", { cwd, mcpServers: [] }))?.sessionId ?? null;
} catch (error) {
return fatal(`dsh couldn't open a conversation (${error.message})`, 1);
}
if (!state.sessionId) return fatal("dsh opened a conversation without naming it", 1);
}
writeLaunchRecord({ nativeRef: state.sessionId, producer });
say(`[openrig-dsh] ready · ${producer} · conversation ${state.sessionId}`);
say(`[openrig-dsh] DSH_HOME ${home} · DSH_PERMISSION_MODE ${agentEnv.DSH_PERMISSION_MODE}`);
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
workBeat = setInterval(() => {
if (working()) {
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
}
}, WORK_BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
for (const ask of state.asks) ask.end("exited");
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3_000).unref();
});
}
process.on("SIGINT", abort);
const framer = new InputFramer();
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdout.write("\x1b[?2004h");
tty = true;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
for (const event of framer.push(chunk)) {
if (event.type === "echo") process.stdout.write(event.text);
else if (event.type === "erase") process.stdout.write("\b \b");
else if (event.type === "abort") { process.stdout.write("\n"); abort(); }
else if (event.type === "block") { process.stdout.write("\n"); submit(event.text); }
}
});
} else {
createInterface({ input: process.stdin }).on("line", (line) => {
if (line.trim() === "/abort") abort();
else if (line.trim()) submit(line);
});
}
if (firstInput !== null) submit(firstInput);
else postIdle("idle_heartbeat");
}
const invoked = (() => {
try { return process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null; } catch { return null; }
})();
if (invoked === import.meta.url) {
main().catch((error) => { console.error(`openrig-dsh-runner: ${error.stack ?? error}`); process.exit(1); });
}
@@ -1,7 +1,8 @@
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
# the example launcher), before the seat's first start.
#
# Hermes runs every tool command in one gateway per machine, with the gateway's environment. For each session it swaps
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
# the gateway's environment. For each session it swaps
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
@@ -5,8 +5,9 @@ command: openrig-hermes-launch # no model, provider or --max-turns here: they
maintainer: Your name or team
limits:
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
- closing a seat's pane detaches it; Hermes finishes any accepted turn, and its gateway exits when idle
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -24,6 +24,21 @@ if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
# resume this launcher applies no OpenRig model.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
case "$OPENRIG_MODEL" in
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
else
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
fi ;;
esac
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
@@ -0,0 +1,5 @@
# OpenShell reference driver
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
@@ -0,0 +1,100 @@
#!/bin/sh
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
# folder, and takes the first input as its first prompt;
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
# an id can open another session whose id starts the same way, so neither is used to resume.
set -eu
pi="${OPENRIG_PI:-pi}"
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
sessions="$state/sessions"
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
# login and settings in this folder, before its first start.
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
case "$PI_CODING_AGENT_DIR" in
"~") PI_CODING_AGENT_DIR="$HOME" ;;
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
esac
export PI_CODING_AGENT_DIR
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
set -f
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
case "$skill" in
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
esac
if [ -d "$skills_from/$skill" ]; then
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
else
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
fi
done
set +f
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
valid_id() {
case "$1" in
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
esac
}
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
set --
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input="${first_input%.}"
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
if ! valid_id "$id"; then
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
exit 2
fi
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
node="${OPENRIG_PI_NODE:-node}"
if ! command -v "$node" >/dev/null 2>&1; then
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
exit 1
fi
found="" count=0
for file in "$sessions"/*_"$id".jsonl; do
[ -s "$file" ] || continue
if head -n 1 "$file" | "$node" -e '
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
catch { process.exit(1); }
});' "$id"; then
found="$file" count=$((count + 1))
fi
done
if [ "$count" -ne 1 ]; then
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
exit 1
fi
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
if ! valid_id "$OPENRIG_CONVERSATION"; then
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
exit 2
fi
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -0,0 +1,15 @@
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format: 1
name: pi-tui
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer: Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
names_conversation: true
first_input: true
+260 -4
View File
@@ -4,6 +4,36 @@ A community harness is a coding harness an outside contributor maintains. You in
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
it never reads an unsupported capability as idle, working, ready or verified.
| Capability | Supported | How it works, or what you see instead |
|---|---|---|
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
@@ -21,6 +51,8 @@ launch_result: true # optional: the launcher reports the conversatio
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
startup_assisted: true # optional: startup submission needs a person's check (below)
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
@@ -117,6 +149,45 @@ For a reported conversation, OpenRig also says so plainly when:
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## Runner presets (`runner: true`)
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
without `launch_result` is refused.
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
field existed reads as plain.
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
unavailable.
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
Enter as one prompt.
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
- the seat, as `sessionName` and `nodeId`;
- `runtime`, the preset's name;
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
- an increasing `sequence`, and `occurredAt`;
- `hookEvent` and `subtype`, as in the table.
| The runner posts | When | The seat reads |
|---|---|---|
| `UserPromptSubmit` | it gave the agent a prompt | working |
| `active` or `PreToolUse` | the agent is working | working |
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
| `Stop` / the turn's stop reason | the turn ended | idle |
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
@@ -128,7 +199,9 @@ Agent's CLI.
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in one gateway per machine, with that gateway's
- **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
@@ -139,7 +212,8 @@ Agent's CLI.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted, and the seat's gateway exits once idle. The preset says so in its limits.
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.
@@ -151,13 +225,18 @@ OpenClaw's CLI.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **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.
- **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.
@@ -179,8 +258,59 @@ CLI.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
## An example runner: dsh
[`community-harness-examples/dsh/`](community-harness-examples/dsh/) has a runner preset (`runner: true`) and a small runner
for DeepSeek Harness, which speaks ACP on stdio and has no terminal UI of its own. It's written for dsh 0.2.1-rc.2 and
checked only against a stand-in agent.
- **The runner:**
- it opens the conversation and writes the launch record;
- it turns each submitted block into one prompt;
- it reports turn boundaries as activity hooks;
- it cancels on `/abort` or Ctrl-C, as the [runner contract](harness-runner-contract.md) describes.
- **Permissions:** it sends each request to OpenRig, which asks the default person or the seat's override.
- **When no one can be asked, or OpenRig can't answer,** it permits with dsh's own allow option (`allow_once`, else
`allow_always`), which is OpenRig's default. `allow_always` creates a standing grant in dsh.
- **It refuses and holds needs input** only after `--deny`. A person's no when dsh offered no reject option, or a
request OpenRig says isn't the current occupant's, is answered cancelled.
- **Each seat gets its own dsh home,** with dsh's session-log and plugin-list uploads to DeepSeek turned off. Give that
home credentials, or put a provider key in the seat's environment.
- **Resume isn't declared.** The runner reopens a conversation by the method dsh advertises, and reports one missing only
on dsh's own not-found error. Declare `resume: true` only after a real dsh has reopened a conversation and refused a
missing one.
Its [README](community-harness-examples/dsh/README.md) has the setup and a check you can run without a model. Copy both
and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
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
@@ -192,3 +322,129 @@ The contributor-owned launcher reads the file and uses its harness’s supported
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Pass the seat's declared model at launch
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
model flag or config key.
OpenRig keeps three things apart:
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
refuses is the harness's own error.
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
out `--model` doesn't show that it keeps its earlier model.
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
mkdir -p "$PI_AGENT_DIR/skills"
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
```
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
For example:
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
counts as guidance, which a community seat doesn't take.
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
argument in your Pi version.
- `first_input` carries text, not files: it doesn't install the skills.
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
made with `handoff-and-complete`, whose next row names it as its source.
## Write your own
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
([above](#what-a-community-harness-gets)).
1. **Choose how the conversation is named.**
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
launcher writes the record.
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
`exec`, and does nothing else.
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
error (or report `error: "missing"`) instead of creating one.
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
conversation.
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
- List what doesn't work under `limits`.
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
yours.
## The promotion test
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
What the maintainers look at:
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
- a seat starts and takes its first turn (`rig up`);
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
4. **The built-in runtimes are unchanged.**
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
There's no score or threshold. The maintainers weigh the evidence.
+210
View File
@@ -0,0 +1,210 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **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`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
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`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`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>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **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.
+40 -11
View File
@@ -1,7 +1,8 @@
# Edge Types Reference
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
The fallback escalation section and deferred-target exceptions below describe #605.
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
---
@@ -16,8 +17,7 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
`spawned_by` and `can_observe`):
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
@@ -25,13 +25,13 @@ Five edge kinds are accepted by the pod-aware validator (the legacy flat-node fo
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | no | Source escalates to target |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
## What Edges Actually Do Today
### Launch ordering
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
@@ -46,13 +46,42 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
@@ -64,7 +93,7 @@ node, in creation order, whose session is running, or else the first node with a
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to`)
daemon path that follows `delegates_to` or fallback `escalates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -128,7 +157,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
@@ -0,0 +1,138 @@
# 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:
```text
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
observe(binding, execution) -> observedState | unsupported | unconfirmed
export(binding, artifactRequest) -> publicationReceipt
shutdown(binding, execution, selectedEffect) -> effectReceipt
```
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
+180
View File
@@ -0,0 +1,180 @@
# 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
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
# People: the second and later ones are allowed now
rig gateway human add sarah --display-name "Sarah" \
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
# A shared channel
rig gateway channel add acme-customers --display-name "Acme customers" \
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
# See everyone, with organization, groups, the default and each participant's policy
rig gateway participants
rig gateway participants --organization acme --json
rig gateway human show sarah
```
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
non-terminal rows are addressed to it unless you pass `--force`.
## Policy is context, not enforcement
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
## The default person
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
go to:
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
2. otherwise the only registered person, so a one-person install needs no choice;
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
waits, and the others name the command to run.
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
## Shared-channel conversations
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat, without registering
each of them.
**Setup:**
1. Invite the OpenRig Slack app to the channel.
2. Register the channel: `rig gateway channel add acme-customers --display-name "Acme customers" --binding
"slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme --policy-file ./acme-customers.md`.
3. Send its new messages to the support seat: `rig slack channel-map set support-rig C0ACME --inbound
support@support-rig`. Without a map, they go to the inbound destination.
A channel works on its own: no registered person is needed for it to converse.
**What the seat sees:** each message lands as a row from the channel (`acme-customers@external`). Its body ends with a
labelled block: the channel, its organization and groups, who wrote (the Slack user, marked as not a registered person),
and the channel's effective policy. The row is tagged `participant:acme-customers`, `shared-channel` and
`slack-sender:<user id>`. A registered person writing in the channel stays themselves; the block only says where they
wrote.
The channel's policy is the labelled block OpenRig adds at the end of the row, after the message. Anything above it is
the sender's own words, even if it looks like policy. `rig gateway participants --json` is the source.
**Answering:** reply into the customer's thread with an update to the channel:
```bash
rig queue create --destination acme-customers@external --human-intent update --reply-to <their row's id> \
--summary "Your invoice" --body "It's on your account page."
```
A later update with the same `--reply-to` lands in the same thread. An update without `--reply-to` posts to the channel
on its own. Posts to a channel mention nobody.
**What a channel can't do:** its messages are correspondence. They never answer, approve, close or resume anyone's ask.
You can still send an ask to the channel; the queue accepts it with a warning that nothing there will settle it, so read
the replies and close it yourself. Its questions show in the channel as a list, without buttons, and ask for a reply in
the thread.
## Where it's stored
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
`humans.generated.yaml`. Nothing new is written into them.
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
The commands above report the problem, and with several people no default is chosen until it's fixed.
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
remove` also removes the person's placement, and the default if it was them.
## Upgrading
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
their addresses, and no new file is written until you use one of the commands above.
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
user's authority, which only a current TUI sends.
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
## Rolling back to 0.7.2 or earlier
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
- **every registered person still loads**, and each can still answer their own asks from Slack;
- **another person's reply is guarded less on 0.7.1 and 0.7.0:** there, a different person's Slack reply in the thread
of a question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as
an answer row (it still can't close the ask);
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
has no default person, and pairing needs `--human`;
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
answered by the primary user keep their notes;
- **shared channels are unknown:** their customers' messages are refused again, as from any unregistered sender, and
rows addressed to a channel become undeliverable on the older version;
- **organizations, groups and policy aren't shown.**
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
+239
View File
@@ -0,0 +1,239 @@
# 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.
```
+--------------------+ HTTP Loopback +--------------------+
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
+--------------------+ +--------------------+
|
Platform-specific transport
v
+--------------------+
| External Platform |
| (Mobile / Client) |
+--------------------+
```
---
## 1. System Topology & Custody Boundaries
### Outbound Custody & Recovery
- 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`.
- Requests require `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` matching a configured, ready relay profile.
- 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.
### Required Secrets Keys
```ini
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
```
- `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.
---
## 3. Wire Protocol Specifications
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
```json
{
"v": 1,
"deliveryId": "dec-019283-abc",
"recipient": { "handle": "alice-ops" },
"intent": "decision",
"outcome": "interrupt",
"summary": "Database Migration Approval",
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
}
```
- `v`: Protocol version integer (`1`).
- `deliveryId`: Stable whole-decision delivery identifier (`<decisionId>`).
- `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`).
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
```json
{
"eventId": "evt-relay-20261107-001",
"sender": { "handle": "alice-ops" },
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
"text": "Approved. Proceed with rollout.",
"ordering": "after-question"
}
```
- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`).
- `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`).
### Binding Syntax
```
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
```
- `kind`: `relay`.
- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`).
- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`).
- `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`):
```bash
mkdir -p "$HOME/.config/openrig-relay"
touch "$HOME/.config/openrig-relay/relay.env"
chmod 600 "$HOME/.config/openrig-relay/relay.env"
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
EOF
```
### Step 2: Register Human Binding
```bash
# Add a person with a primary relay binding
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
# 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. |
### Conservative Seen-Ledger Recovery (`seen_store_unavailable`)
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`.
+112 -18
View File
@@ -55,7 +55,8 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> `.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.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
@@ -75,8 +76,17 @@ You need 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 (see the [compatibility
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource.
For a tarball or manual Node install on Linux, put its bin directory on PATH in
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
Check `bash -lc 'command -v node rig'`: both paths must appear in the output.
Use Codex's tool shell for this check if it differs from Bash.
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
history](../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
@@ -191,16 +201,17 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
`FAILED [n/4] <command or check> (exit <code>)`. Where a provider isn't signed in yet, step 4 ends that way and
`rig setup` lists each sign-in under "Some steps need attention". If the only remaining failures are provider
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
`rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider
sign-ins, the install steps finished: sign in to each selected provider as below, then continue at
[Start the kernel](#start-the-kernel-and-check-its-state). To install only what the selected providers need, go step
by step instead:
@@ -259,6 +270,17 @@ opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
the running choice.
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
@@ -290,6 +312,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
@@ -331,6 +364,16 @@ every seat while those seats still run the sessions it started, reports that
restore's outcome instead of starting another. Explicitly repeating the saved
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
`rig start --last` restores a rig unless every seat it launched was stopped on
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
once, by rig id when names are shared; it records the stop, and `--last` then
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
bring it back with `rig up kernel --existing`.
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
restore to finish, then replaces only the named seats that still hold the exact
sessions startup launched. Other seats keep running. An operator's conversation
@@ -472,7 +515,7 @@ different points in the same journey:
| What you see | Why and what to do next |
| --- | --- |
| Codex offers to switch to a cheaper model near a rate limit | This is Codex's own menu. Ask the person to choose in the seat's terminal; do not silently change the team's configured model. OpenRig disables Codex's startup update check for managed launches, but does not answer this model-choice menu. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `FAILED [4/4]` with only selected-provider sign-ins outstanding means the package installed but those logins still need the person. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `INSTALLED, SETUP NOT COMPLETE` means the CLI is installed but setup needs attention; follow its output and any Fix, then rerun `rig setup`. |
| The installing harness refuses a tool call or asks for permission | Its native rules, sandbox or automatic permission decision apply before OpenRig can run. Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config) as well as approve them. Show the specific refusal and ask the person to review it in the harness permission controls or run that named step themselves. Keep their chosen restrictions; a refused call does not prove the OpenRig command failed. |
| Herdr or Ghostty installation is unavailable or declined | Herdr is optional for the view: `--window` uses plain tmux when Herdr is absent, and its install failure is a warning. On macOS, Terminal.app is the fallback when supported Ghostty is absent. An explicitly requested Ghostty install can fail setup; resolve that install or decline it with `--no-ghostty`, preserving earlier choices. Address separate required-tool or login failures. Existing cmux remains supported. |
| macOS asks for Automation access, or the window action is denied | The command asks the desktop terminal app to open a new surface. Let the person answer the system prompt. Read the error and inspect any new window before retrying an uncertain action; do not silently switch apps and open a duplicate. |
@@ -499,7 +542,12 @@ rig terminal open saved:kernel --provider herdr --json
rig terminal open saved:kernel --provider cmux --json
```
Use `rig terminal status --json` to inspect provider availability and liveness.
Use `rig terminal status --json` to inspect provider availability, liveness and the
daemon's effective tmux server. To keep managed seats out of your personal tmux
session list, opt into [an isolated server](tmux-servers.md) with
`rig config set terminal.tmux_server isolated`; existing live seats defer the
switch until they are stopped and the daemon restarts.
To keep rigs in separate Herdr sessions, select one explicitly:
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
`--session default` selects Herdr's default session; omitting the option keeps
@@ -698,9 +746,11 @@ partial terminal view is not a healthy team. Each `--provider herdr` open adds
a new workspace, numbered when the name is taken; return to the one already open
when you want to preserve it. From inside Herdr, `rig terminal open <view> --window`
focuses a matching space instead of adding one. Herdr's own status for a seat can
read idle or unknown while OpenRig shows it waiting (`needs-input`); use OpenRig's
view or the Feed to find waiting seats. This is terminal integration, not native
plugin enrollment.
read idle or unknown while OpenRig shows it waiting (`needs-input`), for example in
a narrow tile that wraps Claude's footer, or in a tile that shows the inner tmux
status line (with `terminal.status_bar` on, or for a seat session OpenRig
adopted rather than created). Use OpenRig's view or the Feed to find waiting
seats. This is terminal integration, not native plugin enrollment.
For team views with interactive panes, the open result distinguishes the dashboard
overview from the team's lead conversation and asks whether you can see the team.
@@ -832,21 +882,58 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| --- | --- |
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when all of its seats were lost; check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
**When the shared kernel dashboard is missing.** The dashboard runs in the
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
queue worker does not also restore an unselected terminal. Read
`rig ps --nodes --rig kernel --json` and use the terminal's registered
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
in the commands below with that name. Check the actual pane with
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
that its tmux session is alive.
A daemon or transport error is not proof that the terminal is absent.
- If only the viewing window closed and the TUI is still running, reopen it
with `rig terminal open saved:kernel --window`.
- If the TUI exited but its terminal shell remains, run `rig tui` once in that
existing pane. A fresh seat is unnecessary.
- If the terminal session itself is absent, start only that existing seat:
```sh
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
```
The terminal has no agent conversation to resume. Its fresh-start action
runs `rig tui` after the shell is ready; sibling agent sessions stay
unchanged. This command intentionally omits `--stop`, so it refuses to
replace a live occupant. Check the result before repeating it, then run
`rig capture "<dashboard-session>"` to confirm the TUI and
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
is running, open its view with `rig terminal open saved:kernel --window`.
Opening `saved:kernel` places a view of existing sessions; it cannot create a
missing terminal. Whole-kernel recovery is for the all-seats-lost case in the
table, and `rig up kernel --existing` refuses a kernel with live sessions.
A retained `excluded_from_subset` reason may come from an earlier subset
restore; inspect its event before attributing it to the latest reboot.
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. To check your tmux on
a throwaway socket, without touching your own sessions:
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
step) and `rig doctor` (`tmux_capture` in `rig doctor --json`) run this check for you on a throwaway server and name the
fix when capture fails; they test what tmux does, not its version. To run it by hand, without touching your own
sessions:
```sh
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
@@ -1075,8 +1162,15 @@ rig up ./openrig-specs/rigs/launch/starter/rig.yaml --cwd .
OpenRig's `permission_policy: builtin:yolo` setting selects
`-s danger-full-access -a never` on fresh, resume and fork launches, replacing
the named-profile argument. The profile recipe above remains useful when you
want to maintain those choices in native configuration. The legacy
the named-profile argument. That drops the whole profile, not only its sandbox
and approval settings. To keep a named profile with full access, put full access
in the profile itself and leave `permission_policy` absent, as in the recipe
above. To keep it with a workspace-write or read-only posture that OpenRig
records, use the member's `codex_permissions` (see
[per-member Codex permissions](rig-spec.md#per-member-codex-permissions)):
OpenRig passes `-p` and adds that envelope's flags, and for that seat the
envelope replaces a policy posture such as `builtin:yolo`. Full access isn't an
envelope. The legacy
environment-only `OPENRIG_YOLO=1` path remains sandbox-only when no resolved
policy is present. A standalone `codex --yolo` command is not an OpenRig setting.
At full access, Codex can show its full-access and GPT-5.1 migration notices at
+468
View File
@@ -0,0 +1,468 @@
# Harness and runner contract (DRAFT, published with 0.7.6)
> **Status.** This is a draft for contributors.
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
> (sections 2-4).
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
> (section 7).
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
> runner is qualified beyond that.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
> multiline message with a `/abort` line inside it delivered as one prompt, activity that stayed working through a
> 52-second quiet tool interval and then read idle, cancel by a bare `/abort` typed in the pane, and the same
> conversation resumed by `session/resume` after `rig down --snapshot` and `rig up`, with the runner and its child
> gone after the managed down.
> - **The dsh example runner** (`community-harness-examples/dsh/`): dsh's native `session/request_permission` reached
> a person, whose allow-once took effect and whose reject-once didn't.
>
> **Not shown natively:** a DeepSeek model, the example runner's own resume (its preset declares none), the default
> permit when nobody can be asked (checked without a model only), a typed or Slack answer, a second person's
> answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path is unqualified until
> it's shown.
>
> **Since 0.7.8, on one stack:** the dsh example runner has run with a real dsh 0.2.1-rc.2 on dsh's pi-ai OpenRouter
> chat-completions profile with `deepseek/deepseek-v4-flash`: queue work, activity, an allow-once and a reject-once
> answered by an automated test operator, cancel, and an exact return with resume added to a copy of the preset. A
> missing conversation's refusal hasn't been run, and the preset still declares no resume.
>
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
the same harness can run in many places, and one place can host many harnesses.
## 1. Plain preset or runner preset?
| | Plain community preset | Runner preset (`runner: true`) |
|---|---|---|
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
terms in 0.7.6. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
formats or loaders that exist.
## 2. Declare it: what the preset says, fixed at launch
```yaml
# $OPENRIG_HOME/community-harnesses/dsh.yaml
format: 1
name: dsh
command: dsh-seat # your runner; it starts the agent itself
maintainer: <you>
runner: true # since 0.7.6
launch_result: true # required with runner
resume: true # only after you've checked a real reopen and a refused missing one
limits:
- permission requests go to a person through OpenRig (section 7), and are permitted when no one can be asked
- a prompt whose whole text is /abort can't be sent
- fork unsupported
```
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
moment. Editing the preset changes the next launch, not a running seat.
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
- **The other fields keep their existing meanings:**
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
## 3. Identity: report the conversation once, in the launch record
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
`session/resume`).
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
```
or the agent's own word that it doesn't exist:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
```
- **`producer` is free text** for your versions. There are no structured version or capability fields.
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
- **No record means attention.** For a runner preset:
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
resume reads unconfirmed (section 8).
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
## 4. Activity: report turn boundaries
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
```
| When | `hookEvent` / `subtype` | The seat reads |
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| 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
(`rig seat clear-attention <session> --reason "…"`).
- **Never leave a request pending.**
- **What it couldn't do:**
- answer from the seat's permission setting;
- take answers typed into the pane;
- route an approval to another seat.
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.8,
replaces this as the default.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
### The answered path (0.7.8)
**Only requests the agent actually makes reach anyone.** The harness's own permission mode decides that, and a harness
that runs without asking gains no approval step. Ordinary conversation and `rig send` are unchanged.
**What happens to a request, in the person's terms:**
| Situation | What happens |
|---|---|
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, 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
grant as section 4:
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "generation": "<OPENRIG_OCCUPANT_GENERATION>",
"nativeRef": "<the conversation>", "turnId": "7", "requestId": "<the agent's JSON-RPC id>",
"toolCall": {"title": "Edit src/app.ts", "kind": "edit"},
"options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
{"optionId": "reject", "name": "Reject", "kind": "reject_once"}],
"occurredAt": "2026-10-30T12:00:00Z"}
```
- **A person is asked:** the reply is `{"ok": true, "askId": "…", "created": true}`. OpenRig records the seat's
`permission_prompt`, so it reads needs input. Don't post it yourself.
- **No one can be asked:** the reply is `{"ok": true, "state": "permitted", "optionId": "…", "reason": "…"}`.
Deliver that option.
- **`requestId` and `turnId`** may be strings or numbers. Pass the agent's id unchanged: a number is read as its
decimal string, so `7` and `"7"` are the same request.
- **Resubmitting** the same generation, conversation and request id returns the same `askId`.
2. **Read** the answer with `GET <base>/api/activity/permission-requests/<askId>?waitSeconds=25` (25 at most), and read
again until the agent's turn ends.
- **The `state`** is `pending` (read again, don't cancel), `answered` or `permitted` (each with `optionId`),
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
`stale`.
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
refused (`409 activity_grant_stale`), even during a read's wait.
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
already waiting. They're never the answer. A runner that drops them still keeps this contract.
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
- A later answer isn't sent.
- A relaunch makes the earlier occupant's open requests stale on its own.
**Refusals, at submit or while reading.** One rule covers both. Cancel only a request that certainly isn't the current
occupant's live one, and hold no attention. Anything that may still be a live request permits, as when OpenRig can't
answer. Only the seat's opt-in refuses and holds attention.
| Code | Status | Meaning | The runner |
|---|---|---|---|
| `denied_by_seat` | 409 | The seat's operator opted in to refusal (`--deny`). | refuses and holds attention |
| `options_unsupported` | 422 | No one can be asked, and the agent offered no allow option to permit. | refuses and holds attention |
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
permission nobody chose. 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
advertises: ACP's `session/load` (capability `loadSession`), ACP's `session/resume` (capability
`sessionCapabilities.resume`, which dsh `0.2.1-rc.2` uses), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
- **Telling "missing" from "won't reopen":** an agent may answer one error for both. dsh `0.2.1-rc.2` answers `-32602`
for a conversation it doesn't have and for one already active elsewhere. So don't read an error's code or text as
missing unless the agent defines it that way, as ACP's not-found `-32002` is.
- **Report missing only on the agent's own not-found,** and leave anything else unconfirmed.
- **When the agent advertises `sessionCapabilities.list`,** its `session/list` can show that a conversation exists:
one it lists but won't reopen is unconfirmed, even on a not-found. Follow `nextCursor` (an opaque string, possibly
empty), and treat a page you can't read as deciding nothing.
- **A list doesn't show that a conversation is gone,** unless your agent guarantees it lists every conversation. An
agent may leave out one that another running process holds unsaved; dsh `0.2.1-rc.2` can. So still try the reopen
for a conversation the list doesn't name.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
| The same conversation reopened | the same `nativeRef` | resumed |
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
- **Never start a new conversation on a resume.**
- A conversation saved under another harness's runtime is never handed to your launcher.
## 9. Context, model and shutdown: whose job is what
| Responsibility | OpenRig does | Your preset or runner does |
|---|---|---|
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
## 10. What counts as evidence
- **Keep three things separate:**
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
that `--verify` recognises it.
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
marked done, or an artifact written.
- **Your own tests** should run against a fake agent that covers each section above. The
[runner conformance check](runner-conformance/README.md) does that for you: point it at your runner, and it reports
which promises your runner keeps.
- **The useful real check** for a maintainer:
- a seat starts and reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled;
- a resume reopens the same conversation, and a missing one is refused, never replaced.
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
compatibility evidence for the versions you name, not certification.
## Contributor checklist
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
`subtype`. They're sent in order and never block the agent.
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
- [ ] Every permission request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
person's, or OpenRig's permit. When OpenRig can't answer (404, unreachable, or a refusal section 7's table marks
"permits"), the runner permits with the agent's `allow_once`, else `allow_always`. A refusal that says the request
isn't the current occupant's, or a `refused` answer, means answer `cancelled` and hold nothing. Only
`denied_by_seat`, or no allow option to permit, means refuse at once, show it, and hold needs input until the next
accepted prompt.
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
unconfirmed.
- [ ] Unknown agent requests get `-32601`.
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
## Open questions
1. **Answered:** which method and capability does dsh advertise for reopening a session, at a pinned version? dsh
`0.2.1-rc.2` advertises `sessionCapabilities` `{close, list, resume}`, without `loadSession`, and reopens with
`session/resume` (section 8). This was observed with a stand-in model, not a real one.
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
with queue tasks? 0.7.8 routes them to the default person, or to a person chosen for the seat (section 7). A chosen
seat is still open.
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
harness?
+6 -3
View File
@@ -139,7 +139,9 @@ issue's current status; a similar symptom alone is not a diagnosis.
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
[#273](https://github.com/mvschwarz/openrig/issues/273).
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
- Claude usage limits may not be detected when neither a status line window nor a current
`StopFailure/rate_limit` hook is available: [#99](https://github.com/mvschwarz/openrig/issues/99).
See [Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection) for the inputs and retry behavior.
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
by the team.
@@ -147,8 +149,9 @@ issue's current status; a similar symptom alone is not a diagnosis.
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
- Kernel seats fail within a second of the first start on a distribution tmux whose server exits on `capture-pane`
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). The check and recovery
are in [Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). `rig doctor` checks for
it (`tmux_capture`); the manual check and recovery are in
[Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
+183
View File
@@ -0,0 +1,183 @@
# Hosted members: a tool outside the terminal on your team
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
## Set one up
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
[Rosters](rosters.md)).
3. **Issue its grant:**
```sh
rig mcp token add grok --seat grok@research --rig research
```
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
that rig's seats and work.
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
MCP clients aren't browsers.
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
## How it works its rows
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
- **The member works it** through the MCP tools:
- `rig_queue_list` with `owned` lists its rows;
- `rig_queue_claim` takes one;
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
- **It can send work too:** `rig_queue_create` to its rig's seats.
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
## Asking a person while keeping the work
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
row to the member in progress.
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
question closes, the member's row returns to pending.
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
unlimited delivery.
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
member, so it checks its rows again.
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
## Ring it when work lands
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
entry. The entry holds secret names, never values:
```json
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
```
- **The secrets:**
- `url_ref` names the secret holding the webhook URL.
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
- **The ring:** when a row lands for the member, the daemon POSTs
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## 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,
and nothing else is offered:
| Pair | Tool |
|---|---|
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
| `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).
+11
View File
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
+22
View File
@@ -107,6 +107,28 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
@@ -0,0 +1,84 @@
# Pi tool-selection recovery
Use this when a live Pi conversation has fewer active tools than its operator
currently intends. This does not establish why the selection changed or that
stock Pi/OpenRig lost tools. The motivating report used a customized Pi 1.4.0
deployment; it is not a stock cross-platform reproduction.
## Diagnose before changing the selection
Confirm the exact seat, occupant and conversation, then use an idle boundary.
Preserve pending work and reconcile unknown writes before any replacement.
Keep four things separate:
- **Configured:** the effective merged `defaultTools` setting. Do not infer it
from one settings file; retain any unresolved patterns or modifiers as such.
- **Currently intended:** the tools the operator wants enabled now, including
deliberate exclusions. Configuration alone does not establish that intention.
- **Registered:** names the current Pi runtime knows about.
- **Active:** names returned by `getActiveTools`, which can differ from both.
A live RPC connection does not prove the tools are usable. Record observation
time and conversation binding; an unavailable query stays unknown. Reloading
unchanged settings can retain session-disabled tools by design, so a reload
that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
For RPC seats, an optional [community-maintained helper][helper] provides
read-only status and an explicit command that adds only named intended tools.
It is maintained outside OpenRig, is not bundled, and this link is not a support
claim. Loading it also registers the agent-callable `openrig_runtime_status` and
`openrig_slots` tools; the latter reads a snapshot of the bound rig's agents and
active queue items. It can add a prompt section when a guidance file is set.
Review its [shared implementation][runtime] and
[setup/capability limits][setup] before opting in through Pi's per-seat extension
configuration. Keep the two helper files together; do not install the separate
RepoStew application just to use the neutral bridge. Verify what the target
session actually loaded after the supported activation path.
With that helper already loaded, these are **Pi extension commands**, not new
`rig` commands:
```text
/openrig-status
/openrig-restore-tools read
```
For an agent OpenRig drives over RPC, send each extension command with
`rig send <agent> --raw "<command>"` so it arrives without a messaging envelope;
the usual interactive-prompt guard still applies.
The second command means that `read` is intended now. It does not restore every
configured default. If `bash` was deliberately disabled, leave it disabled
unless the operator explicitly intends to add it too. With no names, this helper
refuses to change the selection.
## Verify the recovery
1. Establish the explicit current intended names. Reject unregistered or hidden
requested tools before changing anything; do not infer replacement names.
2. Preserve current active extras: `setActiveTools` **replaces** the selection,
so pass the union of current active names and only the requested names. It can
ignore names it cannot activate; sending the command is not proof of recovery.
3. Read `getActiveTools` back. Retain a before/after receipt with the intended
names and any missing names, then make a harmless actual call to a recovered
tool within the existing task authority. Listed-active is not proof of usable
permissions or working execution.
This changes tool selection, not permission policy, task authority or conversation
identity. It does not automatically persist a new branch selection; verify again
after branch navigation or reload. If the runtime cannot support or verify this
in place, report that capability gap and follow its supported replacement path
with retained work. Do not manufacture a fresh conversation or claim that another
harness has Pi's capabilities.
[helper]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-openrig.js
[runtime]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-runtime.js
[setup]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/references/harnesses/pi.md
+4
View File
@@ -7,6 +7,10 @@ without daemon-internal knowledge.
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
The instance initializer delegates these exact workspace bytes to this owner.
To inspect or read an exact reconstruction under a selected work home, use
[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home
resolution with `context work-install` and performs no installation or projection.
## Default Shape
`rig config init-workspace` creates the default workspace at
@@ -120,6 +120,11 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
## 6. Submit it to openrig.dev/rigs
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
(see [section 1](#1-lay-out-the-folder)).
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
+21 -4
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.2`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
@@ -90,7 +90,8 @@ Use `codex` or `both` in place of `claude` to match the coding agents you use. A
- 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.
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
@@ -100,12 +101,16 @@ rig status
rig ps --nodes --rig kernel
```
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
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.
`.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,
@@ -193,6 +198,18 @@ it as the step to finish: sign in, then continue at step 3.
otherwise, or when the width can't be measured, the dashboard is on its own page. `saved:kernel` in
`rig terminal open saved:kernel` is the name of this saved view.
## Learn it in this order
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
3. **One command:** `rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
lists every command.
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
your version.
## More
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
+90
View File
@@ -0,0 +1,90 @@
# Read a reconstruction of selected work
A reconstruction records an author's working understanding, its evidence routes,
constraints and gaps. `rig context reconstruction` reads an exact immutable
`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch
remote context, write records or transfer the author's authority.
Inspect the purpose, depth, author-declared coverage, observation dates, complete
source manifest and approximate size before reading a view:
```bash
rig context reconstruction inspect <full-sha256> --project example --mission task --slice 01-example
rig context reconstruction view <full-sha256> --project example --mission task --slice 01-example --item decision --json
```
Without `--store`, the store is `reconstructions/` under the selected project,
mission or slice home. Project selection follows `context work-install`: an
explicit catalog project, the only entry, the calling rig's association, the
deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an
error. The project's configured `missions.root`, declared mission membership,
slice directory names and slice frontmatter IDs are honored. A workspace
without a catalog can use its own project home. No skill installation or context
projection happens during this lookup.
Use `--store /absolute/local/store` for an imported store without resolving a
project. Supplying work selectors as well still validates that work home.
Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision.
## Faithful views and gaps
Repeat `--item`, `--category` or `--tag` to select their union. A filtered view
always retains authority, constraint and gap items, plus transitive item
dependencies. It names omitted item IDs and uncited evidence routes, and keeps
declared reconstruction coverage separate from actual view coverage. Inspect
always provides the complete manifest; a filtered view provides cited routes.
A full view also includes uncited registered routes.
Base records, deltas and pinned compositions retain inherited observation dates.
A revision's creation date does not refresh those observations. Composition
members do not imply an atomic capture. Authors are claimed attribution, and
coverage is authored description rather than proof of runtime state or acceptance.
Unavailable records, missing selections, dependencies or citations make the
result `complete: false` and set exit status 1. Available composition members
remain visible with the missing member's exact store and revision. Unknown
schemas, invalid content hashes and unsupported numeric representations are
reported explicitly. The initial reader supports safe integer JSON numbers;
floating-point/exponent and unsafe integer representations are rejected rather
than assigned a different ID. Unicode keys follow v0's codepoint sorting.
## Relocate a store without changing its records
Parent and member pointers include absolute source-store paths in the hash.
Do not edit those paths in immutable records. Copy their closure into local
custody, then supply a separate JSON mapping:
```json
[
{
"sourceStore": "/original/work/reconstructions",
"localStore": "/local/import/reconstructions",
"sourceHost": "original-host"
}
]
```
```bash
rig context reconstruction inspect <full-sha256> --store /local/import/reconstructions --store-map /local/map.json
```
The mapping is read-time location information, not an identity registry or a
remote fetch permission. It preserves source-host provenance. Since v0 pointers
do not contain a host discriminator, duplicate mappings for the same source
store are ambiguous and rejected. A store path that exists on this host can be
read directly; a path that has moved needs a mapping to its local location.
## Bounds and byte checks
Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64
record references and 128 JSON nesting levels. Symlink revision files are not
followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB.
Oversized output fails explicitly rather than silently truncating a purportedly
complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer.
`--check-sources` hashes declared local regular files within 128 files and 64 MiB
total. It reports changed, unchanged, unavailable or unchecked bytes without
executing source commands, fetching opaque routes or updating observations.
Discovery declarations are shown but not scanned. Unchanged known files do not
establish semantic freshness or detect new queue decisions. There is no queue
discovery cursor, native writer or continuity-consumer replacement in this reader.
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
- Maximum gzip compression (level 9)
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
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.
---
+415 -13
View File
@@ -2,7 +2,7 @@
Version: 0.2 (pod-aware)
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources since then checked)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/launch-wrapper.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
@@ -180,7 +180,7 @@ edges:
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`, 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; a link, a conflicting or unreadable Codex config layer, or a resume keeps the repository file. See [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). See "Choosing the Claude instruction file" below. |
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
@@ -290,6 +290,77 @@ managed-launch path. Other version pairs and native enforcement need their own c
The launch still uses inline JSON, subject to the operating system's argument-size
limits.
### Per-member Codex permissions
Codex members can record their own sandbox and approval choice, even when they share a project folder:
```yaml
codex_permissions:
sandbox: workspace-write # workspace-write or read-only
approval: never # never, on-request or untrusted
network_access: false # workspace-write only
writable_roots: ["/abs/lease"] # workspace-write only; absolute paths
exclude_tmpdir_env_var: false # workspace-write only
exclude_slash_tmp: false # workspace-write only
```
OpenRig emits this envelope as explicit `-s`, `-a` and `sandbox_workspace_write.*` flags on
every managed launch and resume of the seat (fresh, both resume paths, restore and same-seat
handover), so a named `codex_config_profile` or a policy posture doesn't override those fields.
Omitted workspace-write fields are recorded as `false` or `[]` and emitted as such. Full access
is not an envelope: it stays the `full_bypass` mode. The envelope is the seat's explicit choice,
so team and kernel launch defaults don't apply to it, and status names the policy posture or
ambient `OPENRIG_YOLO` it replaces. With workspace-write, OpenRig still adds its queue-state root
and, on a fresh launch, git metadata folders; with read-only it adds no writable roots. It adds
no network setting of its own. Codex's implicit roots (the working directory and temporary
folders) still apply: this is a native sandbox and approval selection, not a complete
filesystem or credential-isolation guarantee. OpenRig's tests prove these launch flags, not
that Codex enforces them, so check the seat's actual behaviour before relying on a narrower
envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after a downgrade
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
runtimes reject it.
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
audited permission command:
```sh
rig seat set-permissions dev-impl@team --codex-permissions envelope.json --reason "Narrow the implementer"
rig seat set-permissions dev-impl@team --codex-profile lease-impl --reason "Use the lease profile"
rig seat status dev-impl@team --json
```
Choose one of `--mode`, `--rules`, `--codex-permissions` or `--codex-profile` per call. A seat
keeps one explicit permission choice: setting an envelope replaces a recorded mode, and setting
a mode (including `inherit`) clears the envelope, in the same audited write that names both.
A runtime switch keeps the envelope while the seat runs Claude Code and applies it again when
`rig seat set-runtime` brings the seat back to Codex. A switch back to Codex with an explicit
`--permission-mode` replaces the envelope when the switch commits, and the switch's plan and
event name it. A switch that doesn't commit leaves it in place.
A profile name is separate from the envelope and from approval: a resumed conversation keeps
its saved approval unless an envelope sets one. `--codex-profile inherit` clears the profile.
The profile command checks the profile in the config home this seat's launches use: the seat's
own `CODEX_HOME` when its `env:` declares one, else the daemon's explicit `CODEX_HOME`. There it
reports an absent file (Codex loads it as an empty layer, so base config applies), records the
file's hash so a later check can report a change, and refuses a profile that fails to load,
keeping the previous one. When neither sets a `CODEX_HOME`, a seat's pane keeps its own, and the
command reports the profile as unverified rather than checking a different home. Every launch
still runs the native profile check.
Updates leave the running process and history unchanged; they apply at the next separately
authorized managed launch or resume. Restore reads the seat's current record, so an older
snapshot doesn't bring back a replaced profile. A fork copies the source's current profile and
envelope independently. Export contains the current record. A new generation from `rig up
<spec>` starts from the spec. An invalid or unreadable envelope refuses that seat's launch, fork
or export with a repair message rather than launching broader. Without the field or a recorded
update, launch arguments are unchanged.
**Downgrade:** an older OpenRig keeps the stored envelope but doesn't read it, so its launches
of the seat use the earlier posture decision, which can be broader. This is not
restriction-preserving. Keep a copy of the intended envelope and establish equivalent native
restrictions before using an older managed-launch path. Native enforcement must be checked on
the Codex version and configuration being used.
### Team launch defaults
A non-kernel Claude or Codex seat with no authored permission policy, explicit
@@ -333,6 +404,22 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o
after launch cannot supply a decision, leaving Claude's remaining native rules
in effect. The hook is a convenience policy, not a containment boundary.
Claude sessions launched with a named `team` or `kernel` hook policy read the
installed built-in lists on each Bash hook call. Replacing those files at the
same path changes the hook's lists on its next call; native inline allowances
remain as launched. Sessions launched with an encoded policy keep their launch
lists. A session retaining a versioned runtime path continues to use the files
at that path, so installing another version elsewhere does not change its hook.
This does not reload all permission settings in a running session.
An in-place rollback to a hook from before named policies has a different effect:
that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle
asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected
Claude sessions before relying on those asks after such a rollback. Native
personal, project and managed restrictions are not removed. This applies when
the rollback replaces the hook at the path a running named-policy session uses;
it does not describe every rollback or sessions using a retained versioned path.
Codex keeps `workspace-write` with its existing approval policy and receives the
configured OpenRig workspace root plus its pod's shared state directory as
additional writable directories. The team workspace preparer creates missing
@@ -440,9 +527,9 @@ pods:
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
@@ -455,6 +542,118 @@ only the stored `env:`. Seats may share a home. Live usage refresh (`rig provide
daemon's own logins; reading each seat's home would need a native reader per home. OpenRig doesn't copy logins or conversation history between homes, so a conversation saved under a seat's earlier home
doesn't resume after the seat moves to another one. The `openrig-user` skill has the setup steps.
**A seat's own subscription token.** A Claude member can start on a prepared subscription token from its first launch,
with no conversation to switch first. Name the token file beside its config dir:
```yaml
- id: dev
runtime: claude-code
env:
CLAUDE_CONFIG_DIR: /abs/claude-dev
claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
```
The label and the absolute path are stored on the seat when the rig is created, as `rig provider switch --seat <seat>
--to-account <label> --token-file <path>` stores them. Its launches run the same token step, which reads the file as
Claude starts; a launch path that can't run it (a pane shell other than a POSIX one, for example) starts without it and
warns. The file's contents never pass through OpenRig: validation doesn't read it, and they don't reach the spec, the
seat's record or the launch command. A missing, empty or unreadable file doesn't refuse the spec; the seat starts on its
config dir's stored login (or a token the daemon gives every seat) and says so. A launch also warns when a setting Claude
Code ranks above the token would be used instead: an `apiKeyHelper`, or an outranking `env` entry in the user, project
or local settings. Managed settings aren't inspected. `rig export` writes the seat's current reference, except for a seat
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
while staying in its rig:
```sh
rig provider switch --seat dev-impl@my-rig --to-account api-account \
--account-home /home/me/accounts/codex-api --model gpt-5.5 --reason "Use the API budget" --dry-run
rig seat set-runtime dev-impl@my-rig --runtime claude-code \
--account-home /home/me/accounts/claude-api --model claude-sonnet-4-6 --reason "Codex limit"
```
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
```sh
rig provider switch --seat pi-worker@my-rig --to-account work \
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
# Run again without --dry-run to stage the successor and request its confirmation.
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
```
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
and another runtime is not supported by this account-home feature.
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
model, profile and permission selection together. The seat keeps its address, edges, queue rows and claims. If OpenRig
refuses the switch before cutover, the predecessor's configuration stays unchanged.
For a Claude plan authenticated with `setup-token`, supply the token file with the home:
```sh
rig provider switch --seat dev-impl@my-rig --to-account claude-plan \
--account-home /home/me/accounts/claude-plan --token-file /home/me/credentials/claude-plan-token \
--reason "The plan reset"
# Across runtimes, use set-runtime with --account-home, --token-file and --account-label.
```
To return to the seat's configured login:
```sh
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset" --dry-run
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset"
# Across runtimes: rig seat set-runtime dev-impl@my-rig --runtime claude-code --clear-account-home --reason "Return to configured login"
```
Clearing the selection also starts a successor with a fresh conversation and a handover. OpenRig removes the saved
account-home override and its token reference only after the successor confirms. The successor starts with the member's
or rig's configured home, falling back to the daemon's selection and then the native default, as an ordinary launch does.
If there was no account-home override, OpenRig preserves any unrelated legacy per-seat token. Clear uses the current
configuration; it does not remember a prior login or restore old credentials. Use clear on its own, without
`--to-account`, `--stored-login`, `--account-home` or a new token reference.
**Anthropic profile limitation.** An active or default profile in Anthropic's configuration directory can override a
stored Claude login. `CLAUDE_CONFIG_DIR` does not isolate that directory, and OpenRig does not change `ANTHROPIC_CONFIG_DIR`
or rewrite global profiles. Check or deselect the profile before using a stored-login account home, and verify the
running account privately.
An isolated Claude launch removes `ANTHROPIC_PROFILE`, `ANTHROPIC_FEDERATION_RULE_ID` and `ANTHROPIC_ORGANIZATION_ID`
from its inherited environment. It also refuses profile selectors in the selected home's or project's settings `env`
blocks. An explicit subscription token takes precedence over these selectors, including in the legacy
subscription-token switch.
OpenRig receives only the token's label and file path. The launch reads the token; if the required file is unusable, no agent
starts. Selecting an API home without `--token-file` clears an older seat token reference when the selection commits.
The home's API helper can stay in its user settings. For a selected plan token, OpenRig refuses a home whose API helper
or credential environment would take precedence over that token. Both modes refuse project credential overrides.
OpenRig leaves those settings unchanged.
The selected home persists across managed launches and restores. These launches exclude inherited Claude/OpenAI
credentials and routing variables so credentials from the daemon or shell cannot override the selected home. OpenRig
refuses to launch if it cannot read the saved selection or the shell cannot enforce isolation. Launches without a home
selection and existing subscription-login switches keep their behavior. A Claude seat using account-home mode must
use that mode for later account changes too.
User-managed provider settings can still affect authentication. After a real tool turn, check which account and billing
identity the runtime used. Do not print tokens or the process environment. The configured home alone cannot verify the
running account.
Before downgrading to a version without account-home switching, stop the managed seats. Select their accounts using
that version's supported setup before resuming. Older versions ignore the saved selection and may launch with inherited
or default credentials. Rolling back the binary alone does not preserve the selected billing account.
OpenRig leaves the imported team file unchanged. Exporting and reimporting a team does not preserve account-home
isolation, so configure it explicitly for each new rig. If a switch is interrupted, inspect it with `rig seat status`
and follow the printed recovery command. Wait until the outcome is known before starting another switch.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
- Spec values are stored in OpenRig's database and in rig snapshots, and `rig export` and bundles copy them, so a secret put there by mistake travels with the rig.
@@ -463,6 +662,52 @@ doesn't resume after the seat moves to another one. The `openrig-user` skill has
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
### Switching credentials while keeping the conversation
`rig provider switch` resumes a running seat in its existing home and pane. A stopped Claude Code
seat isn't relaunched: the switch saves the new reference for its next launch, but only when the
launch it predicts resumes the seat's conversation. That prediction covers the snapshot that
launch would use and restore's automatic periodic carry. It then prints the launch that resumes:
`rig launch <rig> <seat> --snapshot-id <id>` for a saved conversation, or a plain
`rig launch <rig> <seat>` when the resume comes through the periodic carry, which naming a
snapshot would turn off. Otherwise it refuses and changes nothing. `rig provider precheck` gives
the same answer.
For Claude Code, select an API-key file, a subscription-token file, or the home's stored login:
```sh
rig provider precheck --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account plan --token-file /secure/claude-plan-token
rig provider switch --seat dev-a@my-rig --stored-login
```
The API-key file must be private (0600 or 0400), nonempty, and at most 8192 bytes, containing
one key with optional surrounding whitespace. Only the reference is stored. The child reads the
key at launch; key bytes never enter command arguments, snapshots or switch reports. Classic
launches require a supported POSIX pane shell. Missing/invalid API-key files prevent launch.
Settings that could override the key cause a refusal; OpenRig does not edit those settings.
Native managed settings and a signed-in Claude gateway cannot be ruled out; Claude may require API-key approval before its
first turn. Check `/status` after a real tool turn to confirm the authentication method.
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
before running seats there, or prepare/import a native auth profile separately. Switches preserve
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
policy can override `auth.json`; the report remains unverified until native account verification.
Select or clear an account home in a separate operation: `--api-key-file` cannot be combined with
`--account-home` or `--clear-account-home`. A credential switch inside an already selected home
keeps that home.
Both paths retain the conversation and launch settings, interrupt in-flight work, and report
uncertain restarts without retrying. They do not move account homes or start a fresh conversation.
The caller chooses subscription-first order and configures/enforces the API budget; this command
neither selects accounts automatically nor imposes a spending limit. Downgrade only after switching
API-key Claude seats back to subscription credentials: older OpenRig versions cannot read the new
credential kind correctly.
### Choosing the Claude instruction file
OpenRig writes its instructions for Claude Code members into managed blocks in
@@ -524,18 +769,23 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. To keep the profile, put full access in the profile itself, or record a workspace-write or read-only `codex_permissions` envelope, which keeps it. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
| `launch_wrapper` | string[] | no | — | Local built-in `pi`, `codex` and `claude-code` members only. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -544,6 +794,99 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `starter_ref` | object | no | — | `{ name }` of an agent starter (`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) whose files lead the seat's startup. Not valid on terminal members or with a fork. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Claude MCP loading (`runtime: claude-code`)
Set `claude_mcp: none` on an individual member to launch it without MCP servers:
```yaml
members:
- id: reviewer
agent_ref: local:agents/reviewer
profile: default
runtime: claude-code
cwd: .
claude_mcp: none
```
OpenRig passes `--strict-mcp-config --mcp-config '{"mcpServers":{}}'` to
Claude for this seat. Strict mode selects only the supplied empty server set,
excluding servers from user, project, team and plugin configuration. It does
not disable plugin skills or change the seat's permission mode. The choice is
saved on the seat and carried through fresh starts, forks, resumes (including
saved startup context) and handover successors. Export and bundle round trips
preserve it. The same field is supported on legacy `nodes` declarations.
Other agents in the same directory keep their own choices; OpenRig does not
rewrite shared MCP configuration files. Omit the field for Claude's normal
loading. A later launch from an authored spec without the field clears the
seat's saved opt-out once the launch is accepted for startup; a rejected
launch leaves the choice unchanged. Restarting from saved context keeps it.
Only `none` is accepted, and the field is invalid on other runtimes. If the
seat switches to another runtime, the Claude choice stays dormant and applies
again if it switches back. A currently running Claude process is unaffected.
Export describes the current runtime and omits a dormant Claude choice. YAML
exports name each affected agent in a `# Not carried:` comment, which `rig export`
also prints after writing the file; JSON exports keep their existing shape. If
you rebuild from that export and switch back to Claude, restore `claude_mcp: none`
in the Claude declaration. Re-check this setting after downgrading
OpenRig too: older versions can ignore it.
Shutdown resume probes and the displayed native recovery commands (including
the conversation picker) also carry the saved MCP choice. If shutdown cannot
read that choice, it skips the probe and retains the token; shutdown proceeds.
This opt-out is incompatible with an active enterprise `managed-mcp.json`:
[Claude rejects strict overrides of that managed server set](https://code.claude.com/docs/en/managed-mcp#servers-passed-with---mcp-config-or---strict-mcp-config).
OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -574,14 +917,40 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
own defaults/settings apply. Other runtimes ignore this field.
### Launch wrapper (`runtime: pi` and `codex`)
Set `launch_wrapper` on a local built-in `pi`, `codex` or `claude-code` member to prefix its native executable and arguments with a literal argv vector:
```yaml
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: codex
cwd: .
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
```
- **Scope and literal argv:** Local built-in `pi`, `codex` and `claude-code` members, including legacy flat nodes, support `launch_wrapper`; other runtimes reject it, including `[]`. Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
- **Executable resolution:** `launch_wrapper[0]` must be an absolute path or bare command name. Preflight and launch resolve it using the daemon's launch `PATH` and working directory, before adding the seat's `rig` bin directory. Relative paths containing `/`, aliases and functions are unsupported for the wrapper itself.
- **Native child and `exec` contract:** The wrapper prefixes the resolved Codex entry, prefixes the resolved Pi child executable inside the host runner, or prefixes the real Claude executable and native arguments. Managed Claude applies it after rebuilding the environment; pinned continuations retain their executable. The wrapper must `exec` its child in place, preserving working directory, environment, signals, exit status and TTY stdio (Claude/Codex) or RPC pipes (Pi). Supervising wrappers are unsupported.
- **Classic Claude lookup:** Wrapped classic launch supports bash, zsh, sh and dash and requires `command -v claude` to resolve to an executable file; aliases and functions refuse with `launch_wrapper unavailable; no agent started.` To keep custom flags or environment, put them in a `#!` script named `claude` on `PATH`; the wrapper runs that script ([#1477](https://github.com/mvschwarz/openrig/issues/1477)).
- **Fail-closed (never unwraps):** The selected wrapper applies across fresh launch, relaunch, rebuild, resume, fork, restore, handover and supported runtime switches. Invalid stored metadata, non-POSIX panes, unavailable wrapper support, missing session identity or an unusable wrapper refuses launch. Failures after dispatch remain visible in the pane or Pi runner's markers; OpenRig never runs unwrapped, including Pi's long-command (`launch_path_too_long`) host-runner retry.
- **Persistence and clear:** On new seats, absent or `launch_wrapper: []` stores no wrapper. Applying an authored spec to an existing seat retains the stored selection when absent, clears it with `launch_wrapper: []`, and replaces it with a non-empty array. Recreation/import uses the incoming spec; older snapshots preserve the current selection. First-start retry refuses wrapper changes. Runtime switches carry it to supported successors; validation occurs at execution. There is no live edit verb: change the spec and use the supported create/recreate/apply flow. Export and node inventory include the configured value.
- **Boundary:** An argv prefix is not proof of sandbox isolation, tooling availability, memory wiring or governance enforcement. Verify the wrapper and runtime independently.
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
@@ -646,7 +1015,11 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
cycle detection and visible fallback outcomes.
### Pod-Local Edges
@@ -729,6 +1102,22 @@ the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
For member `startup:`, export uses that seat's retained launch recipe (or its pre-launch materialization) rather than guessing from projected files. If that source is unavailable or a member's files came from a different rig root, the YAML names the member's omitted startup; JSON export names omissions in the `X-OpenRig-Export-Warnings` response header. An older seat without a recipe may have had no member startup or an unretained one: inspect its source before re-importing. When member startup files are present in the export, `rig export` checks required paths against the output folder and warns for missing files. The export does not copy member startup files.
`rig export` includes the rig's saved culture file and startup block, and writes
saved pod startup files and actions under their pod. The file and action order,
delivery hints, required flags, orientation and `applies_on` selections are retained.
Pod actions are saved for export when a new rig is created; older rigs and pods
added from a separate expansion may not have those declarations recorded. Export
does not reconstruct them from a member's combined startup context; keep the source
spec when those declarations are needed. This does not change which startup actions
are inherited when a seat is added later.
File paths remain relative to the exported spec's folder. When exporting to a
different folder, supply the referenced startup files there or import with the
original `--rig-root`. The CLI warns once per missing required rig, pod or member
file; missing optional files do not trigger that warning.
### Files
| Field | Type | Required | Default | Description |
@@ -737,14 +1126,14 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation delivers none, apart from putting back a missing managed guidance block. |
#### Delivery Hints
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's 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)). Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
@@ -755,8 +1144,8 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`, `startup_proof`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | Command/text to send, or `authenticated` / `none` for `startup_proof`. |
| `phase` | string | no | `after_files` | When to execute text/commands. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). Proof selection is resolved before projection regardless of phase. |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
| `idempotent` | boolean | yes | — | Whether this action is safe to run again on a restore without the earlier conversation or a retried start. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation runs none (see the startup guide). |
### Startup proof selection
@@ -969,6 +1358,7 @@ spec can pass `rig spec validate` and then fail at preflight or import. Run
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
27. `launch_wrapper` is valid only on `pi`, `codex` and `claude-code` members (`[]` included); it must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
---
@@ -988,3 +1378,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+6
View File
@@ -86,3 +86,9 @@ UNKNOWN is `{ "status": "unknown", "reason": "..." }`; older daemons may yield
`null`. `--full` and `--fields modelObservation` retain the full object, including
its status and occupant generation. Human output and roster reports use the full
labelled observation.
A member marked `"hosted": true` is a hosted member: a tool outside the terminal that reaches the team over MCP, with no node or pane.
`rig_team` lists it with its capabilities and caveat, no pane and activity unknown, unless its seat is one of the rig's nodes.
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
@@ -0,0 +1,77 @@
# Runner conformance check
A runnable check for runner authors. Point it at your runner and it reports which promises of the
[harness and runner contract](../harness-runner-contract.md) your runner keeps. It needs Node and tmux, and no OpenRig
daemon, model, credentials or network.
```sh
node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-harness --resume --first-input
```
- **`{agent}`** is replaced with the path of the fake ACP agent beside this file. Your runner starts it where it would
start your real agent. It accepts any arguments your runner adds.
- **`--name`** is your preset's name, which your posts carry as `runtime`.
- **`--resume` and `--first-input`** say your preset declares `resume: true` and `first_input: true`. Without them,
those cases are skipped, because OpenRig wouldn't pass that input.
- **`--cases 2,4`** runs only some cases. `--json` prints a machine-readable report. `--keep` keeps each launch's
folder: the agent's message log, the launch record and the scenario.
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
## How it runs
- **Your runner** starts in a private tmux server, with the environment a runner-preset launch gives it:
`OPENRIG_URL`, `OPENRIG_ACTIVITY_HOOK_TOKEN`, `OPENRIG_SESSION_NAME`, `OPENRIG_NODE_ID`, `OPENRIG_RUNTIME`,
`OPENRIG_OCCUPANT_GENERATION`, `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT`, plus `OPENRIG_RESUME_REF` or
`OPENRIG_FIRST_INPUT` when a case needs them. `OPENRIG_HOME` is an empty folder, so nothing reaches a real OpenRig.
- **The fake agent** speaks ACP v1 over stdio. Each case gives it a scenario, such as a slow handshake, a permission
request, a turn that ends cancelled, or an exit mid-turn. It logs every message in both directions. Its own request
ids are numbers, as JSON-RPC agents commonly use, so a runner passes them on unchanged.
- **A local endpoint** stands in for `POST /api/activity/hooks`. It records each post, and refuses one without the
launch's bearer token, as OpenRig does.
- **In cases 4 and 4b,** it also stands in for the answered path's three routes: submit, read with `waitSeconds`, and
withdraw. It checks a submit the way OpenRig does: the seat and generation, `nativeRef` as a non-empty string, `turnId`
and `requestId` as a string or a number (a number reads as its decimal string). Options a person can't be shown (not
2-4 of them, ids that repeat or use other characters, or other kinds) are permitted with the agent's allow option, as OpenRig does. Each launch scripts the answer,
the permit or the refusal it gives, or closes the connection unanswered.
- **Where a launch scripts none of that,** those routes answer 404, as an OpenRig without the answered path does, so
the runner permits.
- **Input arrives the way `rig send` delivers it:** the text is pasted as one bracketed paste with raw newlines, then
Enter is pressed. Ordinary messages carry a From/To envelope.
## The cases
| Case | Contract section | What it checks |
|---|---|---|
| 1 | 3 | No launch record until `session/new` is answered. Then a record holding format 1, this attempt, the preset's name, the agent's conversation id and a `producer` |
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
| 9 | 9 | `OPENRIG_FIRST_INPUT`'s exact bytes, newlines and spaces included, are the first prompt |
## What it doesn't check
- **How OpenRig reads your posts:** working, idle, needs input, unknown after an exit, a send refused while a
permission waits, and an earlier generation not counting. That's OpenRig's side of the contract, and its own tests
cover it.
- **Your real agent:** a passing check isn't a run with your agent. Section 10's real check, with your agent under a
runner preset, is still the useful evidence. Passing this is evidence for the versions you ran, not certification.
- **The model handoff:** `OPENRIG_MODEL` is mapped to your harness's own setting, with no wire this check can see.
- **A resume for a preset without `resume`:** OpenRig doesn't pass `OPENRIG_RESUME_REF` to it.
- **Restarting the agent after a failed handshake:** the contract doesn't rule on that.
- **The chosen person's side of an ask:** who may answer, the Feed and Slack are OpenRig's side, and its own tests
cover them. Routing an ask to a seat is a later change.
+818
View File
@@ -0,0 +1,818 @@
#!/usr/bin/env node
// OpenRig runner conformance check: point it at your runner and see which promises of the runner contract
// (docs/reference/harness-runner-contract.md) it keeps. It needs Node and tmux, and no OpenRig daemon:
// - your runner starts in a private tmux server, with the environment a runner-preset launch gives it;
// - the fake ACP agent beside this file stands in for your agent (your runner starts it where it would start the real one);
// - a small local endpoint stands in for OpenRig's activity route, and records what your runner posts;
// - input arrives the way `rig send` delivers it: one bracketed paste with raw newlines, then Enter.
//
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input]
// [--cases 1,2,4] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]
//
// `{agent}` in the command is replaced with the fake agent's path, an executable that takes any arguments your runner
// adds. For example: --runner 'my-runner --agent-command {agent}'. --resume and --first-input say your preset declares
// `resume: true` and `first_input: true`; without them those cases are skipped, since OpenRig wouldn't pass that input.
// The runner gets a clean environment: PATH, HOME, USER, SHELL, LANG and TMPDIR, the OpenRig variables, and any
// --pass-env names. Exit status: 0 when nothing fails, 1 when a case fails, 2 for a usage or setup problem.
//
// It checks the runner's side only. How OpenRig reads those posts (working, idle, needs input) is OpenRig's own tests'
// job, and one real run with your agent is still the useful check (section 10 of the contract). Passing this is
// evidence for the versions you ran, not certification.
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
function parseArgs(argv) {
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, json: false, keep: false, waitMs: 8000 };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = () => { if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`); return argv[++i]; };
if (flag === "--runner") opts.runner = value();
else if (flag === "--name") opts.name = value();
else if (flag === "--cases") opts.cases = value().split(",").map((c) => c.trim()).filter(Boolean);
else if (flag === "--pass-env") opts.passEnv.push(value());
else if (flag === "--wait-ms") opts.waitMs = Number(value());
else if (flag === "--resume") opts.resume = true;
else if (flag === "--first-input") opts.firstInput = true;
else if (flag === "--json") opts.json = true;
else if (flag === "--keep") opts.keep = true;
else throw new Error(`unknown option ${flag}`);
}
if (!opts.runner) throw new Error("--runner is required");
if (!opts.runner.includes("{agent}")) throw new Error("--runner must contain {agent}, where your runner takes its agent command");
if (!/^[A-Za-z0-9._-]+$/.test(opts.name)) throw new Error("--name must be a preset name (letters, digits, '.', '_' or '-')");
if (!(opts.waitMs > 0)) throw new Error("--wait-ms must be a positive number");
return opts;
}
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (error) { console.error(`${error.message}\n${usage}`); process.exit(2); }
if (spawnSync("tmux", ["-V"]).status !== 0) { console.error("tmux is required: the check drives your runner in a tmux pane, as OpenRig does."); process.exit(2); }
const socket = `openrig-runner-check-${process.pid}`;
const tmux = (...args) => spawnSync("tmux", ["-L", socket, ...args], { encoding: "utf8" });
const sh = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Settling times and per-step bounds grow with --wait-ms on a slow machine; they never shrink below these values. The
// fake agent's own delays and holds grow by the same factor, so each settle keeps its place against what it measures.
const scale = (ms) => Math.round(ms * Math.max(1, opts.waitMs / 8000));
async function waitFor(read, ms = opts.waitMs) {
const deadline = Date.now() + ms;
for (;;) {
const value = read();
if (value) return value;
if (Date.now() > deadline) return undefined;
await sleep(50);
}
}
// The stand-in for OpenRig's activity route and, for a launch that scripts one, its answered permission routes (section 7).
// Each launch has its own token; a request without one is refused, as OpenRig does.
const posts = [];
const tokens = new Map();
const OPTION_ID = /^[A-Za-z0-9_-]{1,40}$/;
const ANSWERABLE = new Set(["allow_once", "allow_always", "reject_once", "reject_always"]);
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
// A JSON-RPC id or prompt number as the runner passed it: a number reads as its decimal string, so 7 and "7" match.
const idText = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : text(value);
const server = createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => { raw += chunk; });
req.on("end", () => {
const token = (req.headers.authorization ?? "").replace(/^Bearer /, "");
let body;
try { body = raw ? JSON.parse(raw) : undefined; } catch { body = undefined; }
const url = new URL(req.url, "http://stand-in");
posts.push({ at: Date.now(), method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), token, body, raw });
const send = (status, payload) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(payload));
const h = tokens.get(token);
if (req.method === "POST" && url.pathname === "/api/activity/hooks") {
if (!h) return send(401, { ok: false });
return body ? send(200, { ok: true }) : send(400, { ok: false, code: "invalid_json" });
}
const ask = url.pathname.match(/^\/api\/activity\/permission-requests(?:\/([^/]+)(\/withdraw)?)?$/);
if (!ask) return send(404, { ok: false, code: "not_found" });
if (!h) return send(401, { ok: false });
// Without a scripted ask, the stand-in is an OpenRig that predates the answered path: 404, so the runner permits.
if (!h.asks) return send(404, { ok: false, code: "not_found" });
// A scripted `drop` closes the connection unanswered: an OpenRig the runner can't reach.
const drop = req.method === "POST" && !ask[1] ? h.asks.script.submit?.drop : req.method === "GET" && h.asks.script.read?.drop;
if (drop) return void res.socket.destroy();
if (req.method === "POST" && !ask[1]) return submitAsk(h, body, raw, send);
const entry = h.asks.byId.get(decodeURIComponent(ask[1] ?? ""));
if (!entry) return send(404, { ok: false, code: "not_found" });
if (req.method === "GET" && h.asks.script.read) return send(h.asks.script.read.status, { ok: false, code: h.asks.script.read.code });
if (req.method === "POST" && ask[2]) {
if (!["turn_cancelled", "turn_ended", "exited"].includes(body?.reason)) return send(400, { ok: false, code: "invalid_permission_request" });
entry.withdrawn = body.reason;
entry.wake();
return send(200, { ok: true, ...askState(h, entry) });
}
if (req.method === "GET" && !ask[2]) {
const waitMs = Math.min(Number(url.searchParams.get("waitSeconds") ?? 0), 25) * 1000;
const reply = () => send(200, { ok: true, ...askState(h, entry) });
if (askState(h, entry).state !== "pending" || !(waitMs > 0)) return reply();
const timer = setTimeout(() => { entry.waiters.delete(reply); reply(); }, waitMs);
entry.waiters.add(() => { clearTimeout(timer); reply(); });
return undefined;
}
return send(404, { ok: false, code: "not_found" });
});
});
// What the scripted ask reads as now: an answer once its time has come, `withdrawn` after a withdraw, otherwise pending. A
// `late` answer is what the stand-in reports after a withdraw, so a runner that keeps reading and delivers it is caught.
function askState(h, entry) {
const script = h.asks.script;
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
const { state, optionId, reason, said } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
}
return { state: "pending" };
}
// Submit, checked as OpenRig checks it: the seat and generation, nativeRef as a string, turnId and requestId as a string
// or a number. When no one can be asked, OpenRig permits with the agent's allow_once, else allow_always: here when the
// script says so, or for options a person can't be shown (2-4 with distinct ids). With no allow option it refuses.
function submitAsk(h, body, raw, send) {
const script = h.asks.script;
const problems = [];
if (!body) return send(400, { ok: false, code: "invalid_json" });
if (body.sessionName !== h.session) return send(404, { ok: false, code: "session_not_found" });
if (!text(body.nativeRef)) problems.push(`nativeRef must be a non-empty string (got ${JSON.stringify(body.nativeRef)})`);
for (const key of ["turnId", "requestId"]) if (!idText(body[key])) problems.push(`${key} must be a non-empty string or a number (got ${JSON.stringify(body[key])})`);
const options = Array.isArray(body.options) ? body.options : null;
if (!options) problems.push("options must be an array");
else if (options.some((o) => !text(o?.optionId) || !text(o?.name) || !text(o?.kind))) problems.push("each option needs optionId, name and kind");
h.asks.submitted.push({ body, problems });
if (problems.length) return send(400, { ok: false, code: "invalid_permission_request", error: problems.join("; ") });
if (body.generation !== h.generation) return send(409, { ok: false, code: "stale_generation" });
if (script.submit?.status) return send(script.submit.status, { ok: false, code: script.submit.code });
const allow = options.find((o) => o.kind === "allow_once") ?? options.find((o) => o.kind === "allow_always");
const permit = (reason) => allow ? send(200, { ok: true, state: "permitted", optionId: allow.optionId, reason })
: send(422, { ok: false, code: "options_unsupported" });
if (script.submit?.permitted) return permit("no one can be asked");
if (options.length < 2 || options.length > 4 || new Set(options.map((o) => o.optionId)).size !== options.length
|| options.some((o) => !OPTION_ID.test(o.optionId) || !ANSWERABLE.has(o.kind))) return permit("these options can't be shown to a person");
const requestId = idText(body.requestId), turnId = idText(body.turnId);
const key = JSON.stringify([body.generation, text(body.nativeRef), requestId]);
const earlier = h.asks.byKey.get(key);
if (earlier) return send(200, { ok: true, askId: earlier.askId, created: false });
if ([...h.asks.byKey.values()].some((e) => e.requestId === requestId && e.turnId !== turnId)) return send(409, { ok: false, code: "duplicate_request" });
const entry = { askId: `ask-${h.label}-${h.asks.byKey.size + 1}`, requestId, turnId, created: Date.now(), waiters: new Set() };
entry.wake = () => { for (const w of [...entry.waiters]) w(); entry.waiters.clear(); };
if (script.answer) setTimeout(entry.wake, script.answer.afterMs ?? 0);
h.asks.byKey.set(key, entry);
h.asks.byId.set(entry.askId, entry);
return send(200, { ok: true, askId: entry.askId, created: true });
}
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const launches = [];
async function launch(label, { scenario = {}, env = {}, asks } = {}) {
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
const cwd = join(dir, "work");
mkdirSync(cwd);
mkdirSync(join(dir, "home"));
mkdirSync(join(dir, "launch"));
const id = randomBytes(4).toString("hex");
const h = {
label, dir, cwd, token: randomBytes(16).toString("hex"), session: `check-${label}@runner-check`, nodeId: `node-${label}-${id}`,
generation: `gen-${id}`, attempt: `attempt-${id}`, recordPath: join(dir, "launch", "record.json"), agentLog: join(dir, "agent.jsonl"),
target: `check-${label}`,
};
if (asks) h.asks = { script: asks, byKey: new Map(), byId: new Map(), submitted: [] };
tokens.set(h.token, h);
writeFileSync(join(dir, "scenario.json"), JSON.stringify(scenario));
const agent = join(dir, "agent");
writeFileSync(agent, `#!/bin/sh\nexec ${sh(process.execPath)} ${sh(join(here, "fake-acp-agent.mjs"))} --scenario ${sh(join(dir, "scenario.json"))} --log ${sh(h.agentLog)} "$@"\n`);
chmodSync(agent, 0o755);
const vars = {
PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, LANG: process.env.LANG,
TMPDIR: process.env.TMPDIR, TERM: "xterm-256color",
...Object.fromEntries(opts.passEnv.map((name) => [name, process.env[name]])),
OPENRIG_HOME: join(dir, "home"), OPENRIG_URL: base, OPENRIG_ACTIVITY_HOOK_TOKEN: h.token, OPENRIG_SESSION_NAME: h.session,
OPENRIG_NODE_ID: h.nodeId, OPENRIG_RUNTIME: opts.name, OPENRIG_OCCUPANT_GENERATION: h.generation,
OPENRIG_LAUNCH_RESULT: h.recordPath, OPENRIG_LAUNCH_ATTEMPT: h.attempt, ...env,
};
const assignments = Object.entries(vars).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${sh(value)}`).join(" ");
const command = opts.runner.replaceAll("{agent}", sh(agent));
writeFileSync(join(dir, "launch.sh"), `#!/bin/sh\ncd ${sh(cwd)} || exit 1\nexec /usr/bin/env -i ${assignments} /bin/sh -c ${sh(command)}\n`);
chmodSync(join(dir, "launch.sh"), 0o755);
const started = tmux("new-session", "-d", "-s", h.target, "-x", "200", "-y", "50", join(dir, "launch.sh"));
if (started.status !== 0) throw new Error(`tmux couldn't start the runner: ${started.stderr.trim()}`);
Object.assign(h, {
log: () => !existsSync(h.agentLog) ? [] : readFileSync(h.agentLog, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)),
received: (method) => h.log().filter((e) => e.dir === "in" && e.msg.method === method),
sent: (method) => h.log().filter((e) => e.dir === "out" && e.msg.method === method),
answerTo: (method) => { const ask = h.sent(method)[0]; return ask && h.log().find((e) => e.dir === "in" && e.msg.id === ask.msg.id && !e.msg.method); },
replyTo: (entry) => h.log().find((e) => e.dir === "out" && e.msg.id === entry.msg.id && !e.msg.method),
events: (event) => h.log().filter((e) => e.dir === "event" && e.event === event),
prompts: () => h.received("session/prompt").map((e) => (e.msg.params?.prompt ?? []).filter((b) => b.type === "text").map((b) => b.text).join("")),
posts: () => posts.filter((p) => p.path === "/api/activity/hooks" && (p.token === h.token || p.body?.sessionName === h.session)),
permissionCalls: (method, suffix = "") => posts.filter((p) => p.token === h.token && p.method === method
&& p.path.startsWith("/api/activity/permission-requests") && p.path.endsWith(suffix)),
hooks: (event, subtype) => h.posts().filter((p) => p.body?.hookEvent === event && (subtype === undefined || p.body?.subtype === subtype)),
record: () => { if (!existsSync(h.recordPath)) return undefined; try { return JSON.parse(readFileSync(h.recordPath, "utf8")); } catch { return { unreadable: true }; } },
paste: (text) => {
const file = join(dir, `paste-${randomBytes(3).toString("hex")}`);
writeFileSync(file, text);
const buffer = `b-${randomBytes(3).toString("hex")}`;
tmux("load-buffer", "-b", buffer, file);
tmux("paste-buffer", "-t", h.target, "-b", buffer, "-d", "-r", "-p");
tmux("send-keys", "-t", h.target, "Enter");
},
keys: (...keys) => tmux("send-keys", "-t", h.target, ...keys),
stop: () => tmux("kill-session", "-t", h.target),
screen: () => tmux("capture-pane", "-p", "-t", h.target).stdout ?? "",
});
launches.push(h);
return h;
}
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
const postedAt = (p) => {
const value = p?.body?.occurredAt;
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
return Number.isFinite(t) ? t : p?.at ?? 0;
};
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
// idle at startup) is never one made during it, however late its POST arrives.
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
function result(id, title, { proposed = false } = {}) {
const r = { id, title, status: "pass", checks: [], ...(proposed ? { proposed: true } : {}) };
r.check = (name, ok, detail) => { r.checks.push({ name, ok: Boolean(ok), ...(ok ? {} : { detail }) }); if (!ok) r.status = "fail"; return ok; };
r.skip = (reason) => { r.status = "skip"; r.reason = reason; return r; };
return r;
}
const cases = {
async "1"() {
const r = result("1", "Handshake: the launch record comes only after the conversation is open");
const h = await launch("1", { scenario: { newDelayMs: scale(2000), sessionId: "fake-session-1" } });
const asked = await waitFor(() => h.received("session/new")[0]);
if (!r.check("the runner opens a conversation with session/new", asked, "no session/new arrived")) return r;
// Until the agent's answer is logged, there must be no record. The agent logs each message before it sends it, so a
// record read before a log that still holds no answer was written before the answer went out. Reading against the
// agent's log, not after a fixed settle, keeps a busy machine from reading a correct runner's record as early.
let early;
await waitFor(() => {
const record = h.record();
if (h.replyTo(asked)) return true;
if (record) early = record;
return early;
}, scale(2000) + opts.waitMs);
r.check("no launch record while session/new is still unanswered", !early, `a record was written early: ${JSON.stringify(early)}`);
const record = await ready(h);
r.check("a launch record arrives after session/new is answered", record, `no record with a nativeRef at ${h.recordPath}`);
if (!record) return r;
const answered = h.replyTo(asked);
r.check("the record is written after the answer", answered && statSync(h.recordPath).mtimeMs >= answered.at - 5, "the record's time precedes the answer");
r.check("format 1, this attempt, this preset", record.format === 1 && record.attempt === h.attempt && record.harness === opts.name,
`format=${record.format} attempt=${record.attempt} harness=${record.harness}`);
r.check("nativeRef is the agent's conversation id", record.nativeRef === "fake-session-1", `nativeRef=${record.nativeRef}`);
r.check("producer names the runner's versions", typeof record.producer === "string" && record.producer.trim(), "no producer");
return r;
},
async "1b"() {
const r = result("1b", "A failed handshake writes no launch record and claims no turn");
for (const [label, scenario] of [["initialize error", { initialize: "error" }], ["initialize exit", { initialize: "exit" }],
["session/new error", { newSession: "error" }], ["session/new exit", { newSession: "exit" }]]) {
const h = await launch(`1b-${label.replace(/[^a-z]+/g, "-")}`, { scenario });
await waitFor(() => h.events("exit")[0] || h.log().some((e) => e.dir === "out" && e.msg.error), opts.waitMs);
await sleep(scale(1500));
const record = h.record();
r.check(`${label}: no conversation in a launch record`, !record?.nativeRef, `record: ${JSON.stringify(record)}`);
r.check(`${label}: no accepted prompt or idle report`, h.hooks("UserPromptSubmit").length === 0 && nonExitStops(h).length === 0,
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
}
return r;
},
async "2"() {
const r = result("2", "Input framing, queued messages and cancel");
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(15000), ignoreCancel: true, endAfterCancelMs: scale(300) }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const block = message(h, "first line\n/abort\nthird line");
h.paste(block);
await waitFor(() => h.prompts().length >= 1);
await waitFor(() => nonExitStops(h).length >= 1, scale(3000));
await sleep(scale(500));
r.check("one pasted block is exactly one session/prompt", h.prompts().length === 1, `got ${h.prompts().length} prompts`);
r.check("the prompt holds the whole block, byte for byte", h.prompts()[0] === block, `got ${JSON.stringify(h.prompts()[0])}`);
h.paste(message(h, "please hold"));
if (!r.check("the next block starts the next turn", await waitFor(() => h.prompts().length >= 2), "no second prompt")) return r;
// A block that arrives while the agent works, with a line reading /abort, is still one message for later.
const queued = message(h, "sent while the agent works\n/abort\nstill part of the message");
h.paste(queued);
await sleep(scale(1200));
r.check("a block sent mid-turn waits for the next turn", h.prompts().length === 2, `got ${h.prompts().length} prompts during the turn`);
r.check("a line reading /abort inside a block isn't control", h.received("session/cancel").length === 0, "the runner sent session/cancel");
const before = Date.now();
h.paste("/abort");
const cancel = await waitFor(() => h.received("session/cancel")[0], scale(3000));
r.check("a block that is exactly /abort sends session/cancel", cancel, "no session/cancel");
const stopped = await waitFor(() => nonExitStops(h, before)[0], scale(3000));
r.check("the cancelled turn reports Stop/cancelled", stopped?.body?.subtype === "cancelled", `got ${stopped ? `Stop/${stopped.body.subtype}` : "no Stop"}`);
await sleep(scale(300));
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
// The agent keeps this turn open until the cancel arrives and then ends it end_turn, so a runner that handles Ctrl-C
// late on a busy machine still has a turn to cancel. Only its delivery is timed, by the per-step bound.
const interrupted = Date.now();
await sleep(scale(200));
h.keys("C-c");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2), "no second session/cancel");
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
return r;
},
async "3"() {
const r = result("3", "Activity: turn boundaries, posted in order with the seat's identity");
const h = await launch("3");
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a short turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
r.check("UserPromptSubmit/accepted when the prompt is given", accepted, `posts: ${h.posts().map((p) => p.body?.hookEvent).join(", ")}`);
r.check("the accepted post names a turnId", accepted && accepted.body.turnId !== undefined && accepted.body.turnId !== "", "no turnId");
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0]);
r.check("Stop/end_turn when the turn ends", end, "no Stop/end_turn");
r.check("accepted comes before the turn's end", accepted && end && accepted.at <= end.at, "out of order");
const all = h.posts();
const missing = all.flatMap((p) => ["sessionName", "nodeId", "runtime", "generation", "sequence", "occurredAt", "hookEvent", "subtype"]
.filter((k) => p.body?.[k] === undefined).map((k) => `${p.body?.hookEvent ?? "?"} lacks ${k}`));
r.check("every post carries the contract's fields", missing.length === 0, missing.join("; "));
r.check("every post uses the hook token", all.every((p) => p.token === h.token), "a post without the bearer token");
r.check("every post names this seat and preset",
all.every((p) => p.body?.sessionName === h.session && p.body?.nodeId === h.nodeId && p.body?.runtime === opts.name),
all.map((p) => `${p.body?.sessionName}/${p.body?.nodeId}/${p.body?.runtime}`).join(", "));
const seqs = all.map((p) => Number(p.body?.sequence));
r.check("sequence rises with each post", seqs.every((s, i) => Number.isFinite(s) && (i === 0 || s > seqs[i - 1])), `sequences: ${seqs.join(", ")}`);
const beats = all.filter((p) => p.body?.livenessSeconds !== undefined);
r.check("livenessSeconds, when given, is a whole number from 1 to 3600",
beats.every((p) => Number.isInteger(p.body.livenessSeconds) && p.body.livenessSeconds >= 1 && p.body.livenessSeconds <= 3600),
beats.map((p) => p.body.livenessSeconds).join(", "));
return r;
},
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
async "3b"() {
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
const quiet = scale(22000);
const seqOf = (p) => Number(p?.body?.sequence);
const at = (p) => Date.parse(p.body.occurredAt);
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a long, quiet turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
const beats = h.hooks("active", "liveness");
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
const timed = h.posts().slice().sort(byTime);
r.check("sequence rises with the runner's own time across every post, beats included",
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
p.paste(message(p, "edit the notes"));
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
const resolvedPost = p.hooks("active", "permission_resolved")[0];
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { submit: { permitted: true } } });
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
q.paste(message(q, "edit the notes"));
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
return r;
},
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
async "4"() {
const r = result("4", "Permissions: permitted when no one can be asked; refused and held only on denied_by_seat or no allow option");
const option = (optionId, kind) => ({ optionId, name: optionId, kind });
const withAlways = [option("allow-once", "allow_once"), option("allow-always", "allow_always"), option("reject-once", "reject_once")];
const rejectOnly = [option("reject-once", "reject_once"), option("reject-always", "reject_always")];
const cells = [
// label, the stand-in's answered path (none: an OpenRig without it, which answers 404), the options, permitted, and
// whether /abort cancels the turn after the refusal
["404", undefined, withAlways, true],
["unreachable", { submit: { drop: true } }, withAlways, true],
["denied_by_seat", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false],
["denied_by_seat, then /abort", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false, true],
["no allow option", undefined, rejectOnly, false],
];
await Promise.all(cells.map(async ([label, asks, options, permitted, abort]) => {
const turns = [{ permission: { options }, holdMs: scale(abort ? 4000 : 1500) }, {}];
const h = await launch(`4-${label.replace(/[^a-z0-9]+/g, "-")}`, { scenario: { turns }, asks });
if (!r.check(`${label}: the runner opens its conversation`, await ready(h), "no launch record")) return;
h.paste(message(h, "edit the notes"));
const asked = await waitFor(() => h.sent("session/request_permission")[0]);
if (!r.check(`${label}: the agent's permission request is sent`, asked, "the turn never reached the request")) return;
// A runner may take a moment to find OpenRig unreachable; everything else is answered at once.
const answer = await waitFor(() => h.answerTo("session/request_permission"), scale(label === "unreachable" ? 10000 : 3000));
if (!r.check(`${label}: the request is answered, not left pending`, answer, "no answer")) return;
if (label !== "unreachable") r.check(`${label}: it's answered at once (within 2 s)`, answer.at - asked.at <= 2000, `${answer.at - asked.at} ms`);
const got = answer.msg.result?.outcome;
if (permitted) {
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === "allow-once",
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
r.check(`${label}: the turn's end is reported as usual`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(4000)),
"no Stop/end_turn after the permit");
r.check(`${label}: nothing is held as needs input`, h.hooks("Notification", "permission_prompt").length === 0, "the runner posted permission_prompt");
return;
}
r.check(`${label}: the answer is a refusal, never an allow option`, got?.outcome === "cancelled",
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
const shown = await waitFor(() => h.hooks("Notification", "permission_prompt")[0], scale(3000));
r.check(`${label}: Notification/permission_prompt is posted`, shown, "no permission_prompt post");
if (label === "denied_by_seat") r.check(`${label}: what was asked is shown in the pane`, await waitFor(() => h.screen().includes("Edit notes.txt"), scale(3000)),
"the pane never showed the tool call's title, Edit notes.txt");
// A cancelled turn is still a turn's end: the refusal stays held until the next accepted prompt.
if (abort) {
h.paste("/abort");
r.check(`${label}: /abort sends session/cancel`, await waitFor(() => h.received("session/cancel")[0], scale(3000)), "no session/cancel");
}
await waitFor(() => h.log().some((e) => e.dir === "out" && e.msg.result?.stopReason), scale(abort ? 6000 : 4000));
await sleep(scale(800));
r.check(`${label}: no idle Stop or beat while holding that attention`, shown && nonExitStops(h, shown.at).length === 0,
`posted ${nonExitStops(h, shown?.at ?? 0).map((p) => `Stop/${p.body.subtype}`).join(", ")}`);
const next = Date.now();
h.paste(message(h, "carry on"));
r.check(`${label}: the next prompt is accepted as usual, which clears it`,
await waitFor(() => h.hooks("UserPromptSubmit", "accepted").some((p) => p.at >= next)), "no accepted post for the next prompt");
}));
return r;
},
// Section 7's answered path: the runner submits the live request, delivers the option that comes back (the person's
// answer, or OpenRig's permit) once while that request is live, and withdraws it when the turn or the agent ends.
async "4b"() {
const r = result("4b", "Answered permissions: the person's answer or OpenRig's permit reaches the live request, and nothing else does");
const options = ["allow-once", "reject-once"];
const submits = (h) => h.permissionCalls("POST").filter((p) => p.path === "/api/activity/permission-requests");
const withdraws = (h) => h.permissionCalls("POST", "/withdraw");
const answers = (h) => { const asked = h.sent("session/request_permission")[0];
return asked ? h.log().filter((e) => e.dir === "in" && e.msg.id === asked.msg.id && !e.msg.method) : []; };
const outcome = (h) => answers(h)[0]?.msg.result?.outcome;
const open = async (label, asks, turns = [{ permission: true }, {}]) => {
const h = await launch(`4b-${label}`, { scenario: { turns }, asks });
if (!await ready(h)) { r.check(`${label}: the runner opens its conversation`, false, "no launch record"); return undefined; }
h.paste(message(h, "edit the notes"));
await waitFor(() => h.sent("session/request_permission")[0]);
return h;
};
// An answer from the chosen person: the offered option, once, and the turn then reports as usual.
for (const [label, optionId] of [["allow", "allow-once"], ["deny", "reject-once"]]) {
const h = await open(label, { answer: { afterMs: 400, state: "answered", optionId } });
if (!h) continue;
const submitted = await waitFor(() => submits(h)[0], scale(3000));
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
const asked = h.sent("session/request_permission")[0];
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
const sent = submitted.body ?? {};
if (label === "allow") {
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
r.check("it uses the hook token", submitted.token === h.token, "no bearer token");
r.check("requestId is the agent's JSON-RPC id", idText(sent.requestId) === String(asked?.msg.id), `requestId=${JSON.stringify(sent.requestId)}`);
r.check("turnId is the accepted prompt's", accepted && idText(sent.turnId) === String(accepted.body.turnId), `turnId=${JSON.stringify(sent.turnId)}`);
r.check("nativeRef and generation name this conversation and occupant", sent.nativeRef === h.record()?.nativeRef && sent.generation === h.generation,
`nativeRef=${sent.nativeRef} generation=${sent.generation}`);
r.check("the agent's options are passed on as offered", JSON.stringify((sent.options ?? []).map((o) => o.optionId)) === JSON.stringify(options),
JSON.stringify(sent.options));
}
const delivered = await waitFor(() => outcome(h), scale(4000));
r.check(`${label}: the agent gets the chosen option`, delivered?.outcome === "selected" && delivered.optionId === optionId, `got ${JSON.stringify(delivered)}`);
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
await sleep(scale(300));
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
if (label === "allow") {
r.check("OpenRig records the prompt; the runner doesn't post permission_prompt itself", h.hooks("Notification", "permission_prompt").length === 0,
"the runner posted permission_prompt for a created ask");
const reads = h.permissionCalls("GET");
// Section 7 bounds the wait at 25 s and no lower; OpenRig's read takes 0 to 25 or none.
r.check("each read waits at most 25 s", reads.length > 0 && reads.every((p) => p.query.waitSeconds === undefined || Number(p.query.waitSeconds) <= 25),
reads.map((p) => p.query.waitSeconds ?? "(none)").join(", "));
r.check("after the answer, the turn's end is reported as usual", h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after delivery");
}
}
// OpenRig's permit, at submit or at a read, is delivered like an answer. So is the runner's own permit when OpenRig
// can't give an answer: a refusal section 7's table marks "permits" (two of them 409s, like stale_generation, so the
// code decides, never the status; a revoked grant can still be the live occupant's, since one is reissued after a lost
// response), a read that gets no answer, or a status, code or read state the section doesn't list. A refusal saying
// the request isn't the current occupant's, and a refused, stale or withdrawn read, can't take an answer: the agent
// gets cancelled, and nothing is held.
const outcomes = [
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
// A person's own words come back beside the outcome, and never change the option delivered.
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
["unreachable read", { read: { drop: true } }, "allow-once"],
["unlisted read status (500)", { read: { status: 500, code: "internal_error" } }, "allow-once"],
["unlisted read state (deferred)", { answer: { afterMs: 300, state: "deferred" } }, "allow-once"],
["stale_generation", { submit: { status: 409, code: "stale_generation" } }, null],
["activity_grant_stale read", { read: { status: 409, code: "activity_grant_stale" } }, null],
["refused read (said_no)", { answer: { afterMs: 300, state: "refused", reason: "said_no" } }, null],
["stale ask", { answer: { afterMs: 300, state: "stale" } }, null],
["withdrawn ask", { answer: { afterMs: 300, state: "withdrawn" } }, null],
];
await Promise.all(outcomes.map(async ([label, asks, optionId]) => {
const h = await open(label.replace(/[^a-z0-9]+/g, "-"), asks, [{ permission: true, holdMs: scale(800) }, {}]);
if (!h) return;
// Section 7 sets no time limit for what follows a read, so the check waits its ordinary per-step bound (--wait-ms),
// and longer when the read got no answer, which a runner may retry a few times before it permits.
const got = await waitFor(() => outcome(h), label === "unreachable read" ? scale(10000) : opts.waitMs);
if (!optionId) {
r.check(`${label}: the agent gets cancelled`, got?.outcome === "cancelled", `got ${JSON.stringify(got)}`);
r.check(`${label}: nothing is held`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000))
&& !h.hooks("Notification", "permission_prompt").length, "no Stop/end_turn after the cancel, or the runner posted permission_prompt");
return;
}
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === optionId, `got ${JSON.stringify(got)}`);
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
await sleep(scale(300));
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
r.check(`${label}: the turn's end is reported as usual`, h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after the permit");
if (label === "permitted at submit") r.check(`${label}: there's no ask to read`, h.permissionCalls("GET").length === 0,
`${h.permissionCalls("GET").length} reads`);
// A read that got no answer leaves OpenRig's ask open, so it must not stay in the person's Feed after the turn.
if (label === "unreachable read" || label.startsWith("unlisted read")) r.check(`${label}: the ask is still withdrawn at the turn's end`,
await waitFor(() => withdraws(h).some((w) => w.body?.reason === "turn_ended"), scale(3000)), `withdraws: ${JSON.stringify(withdraws(h).map((w) => w.body))}`);
}));
// No answer: the request stays pending and visible, a pasted message isn't an answer, and /abort withdraws it, after
// which a late answer isn't delivered.
{
const h = await open("pending", { late: { state: "answered", optionId: "allow-once" } });
if (h) {
await sleep(scale(1500));
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
h.paste(message(h, "allow it"));
await sleep(scale(800));
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
const abortedAt = Date.now();
h.paste("/abort");
const withdrawn = await waitFor(() => withdraws(h)[0], scale(3000));
r.check("pending: /abort withdraws the ask with turn_cancelled", withdrawn?.body?.reason === "turn_cancelled", `withdraw: ${JSON.stringify(withdrawn?.body)}`);
r.check("pending: the agent's request is answered cancelled", (await waitFor(() => outcome(h), scale(3000)))?.outcome === "cancelled",
`got ${JSON.stringify(outcome(h))}`);
// Once the ask is withdrawn no person is still waiting, so the cancelled turn's own end is reported as usual (the
// pasted message above runs as the next turn, so only that turn's Stop/cancelled counts). The contract doesn't
// order the Stop and the withdraw, so the Stop is any after /abort, not only one after the withdraw arrived.
r.check("pending: after /abort, the turn's end is reported, holding nothing",
await waitFor(() => h.hooks("Stop", "cancelled").some((p) => p.at >= abortedAt), scale(3000)), "no Stop/cancelled after /abort");
await sleep(scale(1200));
r.check("pending: an answer after the withdraw isn't delivered", answers(h).every((a) => a.msg.result?.outcome?.outcome !== "selected"),
`answers: ${JSON.stringify(answers(h).map((a) => a.msg.result))}`);
}
}
// The turn ending, or the agent exiting, while the ask waits also withdraws it.
for (const [label, permission, reason] of [["turn end", { waitMs: scale(1500) }, "turn_ended"], ["agent exit", { exitAfterMs: scale(1500) }, "exited"]]) {
const h = await open(label.replace(/[^a-z]+/g, "-"), {}, [{ permission }, {}]);
if (!h) continue;
const withdrawn = await waitFor(() => withdraws(h)[0], scale(4000));
r.check(`${label}: the ask is withdrawn with ${reason}`, withdrawn?.body?.reason === reason, `withdraw: ${JSON.stringify(withdrawn?.body)}`);
// The turn's own end: a Stop with the agent's own stop reason, after the agent answered that turn's session/prompt,
// on either side of the withdraw (the contract doesn't order them). A Stop posted before the turn ended doesn't count.
if (reason === "turn_ended") {
const turnEnd = await waitFor(() => {
const asked = h.sent("session/request_permission")[0];
const prompt = asked && h.received("session/prompt").filter((p) => p.at <= asked.at).at(-1);
return prompt && h.replyTo(prompt);
}, scale(4000));
const stopReason = turnEnd?.msg.result?.stopReason;
r.check(`${label}: nothing is held once the ask is withdrawn`,
stopReason && await waitFor(() => h.hooks("Stop", stopReason).some((p) => p.at >= turnEnd.at), scale(3000)),
`no Stop/${stopReason ?? "(no turn end)"} after the agent ended the turn`);
}
}
return r;
},
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
// them still keeps the contract, so the result is advisory.
async "4c"() {
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
const said = "Only the notes file, please. Leave the rest alone.";
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "edit the notes"));
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
return r;
},
async "5"() {
const r = result("5", "Generation: every post carries the launch's occupant generation");
const h = await launch("5");
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a short turn"));
await waitFor(() => h.hooks("Stop", "end_turn")[0]);
const all = h.posts();
r.check("the runner posted something", all.length > 0, "no posts");
r.check("generation is OPENRIG_OCCUPANT_GENERATION on every post", all.every((p) => p.body?.generation === h.generation),
all.map((p) => p.body?.generation).join(", "));
return r;
},
async "6"() {
const r = result("6", "Resume: the same conversation, or an honest missing or unconfirmed");
if (!opts.resume) return r.skip("the preset doesn't declare resume (--resume): OpenRig passes OPENRIG_RESUME_REF only to one that does");
const reopenCalls = (h) => [...h.received("session/load"), ...h.received("session/resume")];
{
const h = await launch("6-same", { scenario: { knownSessions: ["fake-old-1"] }, env: { OPENRIG_RESUME_REF: "fake-old-1" } });
const record = await ready(h);
const call = reopenCalls(h)[0];
r.check("same: the agent is asked to reopen OPENRIG_RESUME_REF", call?.msg.params?.sessionId === "fake-old-1",
call ? `asked for ${call.msg.params?.sessionId}` : "no session/load or session/resume");
r.check("same: in the launch's working folder", call && realpathSync(String(call.msg.params?.cwd ?? "")) === realpathSync(h.cwd),
`cwd=${call?.msg.params?.cwd}`);
r.check("same: the record reports that conversation", record?.nativeRef === "fake-old-1", `record: ${JSON.stringify(h.record())}`);
r.check("same: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
{
const h = await launch("6-missing", { scenario: { reopenError: "missing" }, env: { OPENRIG_RESUME_REF: "fake-gone" } });
const record = await waitFor(() => h.record());
r.check("missing: the agent's own not-found is reported as error: missing", record?.error === "missing" && !record.nativeRef,
`record: ${JSON.stringify(record)}`);
await sleep(scale(800));
r.check("missing: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
{
const h = await launch("6-other", { scenario: { reopenError: "error" }, env: { OPENRIG_RESUME_REF: "fake-elsewhere" } });
await waitFor(() => reopenCalls(h)[0] && h.log().some((e) => e.dir === "out" && e.msg.error));
await sleep(scale(2500));
const record = h.record();
r.check("other failure: not reported missing or resumed", !record || (record.error && record.error !== "missing" && !record.nativeRef),
`record: ${JSON.stringify(record)}`);
r.check("other failure: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
}
return r;
},
async "7"() {
const r = result("7", "Death mid-turn: Stop/<preset>_exited, never an idle report");
const h = await launch("7", { scenario: { turns: [{ exit: true }] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "this turn kills the agent"));
const died = await waitFor(() => h.events("exit")[0]);
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
return r;
},
async "7b"() {
const r = result("7b", "Errors: a failed prompt is a runtime error, not a permission request");
const h = await launch("7b", { scenario: { turns: [{ error: true }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "this prompt fails"));
const posted = await waitFor(() => h.hooks("Notification", "runtime_error")[0]);
r.check("a failed session/prompt posts Notification/runtime_error", posted,
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
r.check("it isn't reported as a permission request", h.hooks("Notification", "permission_prompt").length === 0, "permission_prompt posted");
return r;
},
// Section 9 marks this duty "(Proposed)", and a runner may list in `limits` what keeps running, so the result is reported
// on its own: never a pass or a fail, and never the exit status.
async "7c"() {
const r = result("7c", "Shutdown: closing the pane ends the agent (proposed in section 9; advisory)", { proposed: true });
const h = await launch("7c", { scenario: { stubborn: true } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const pid = h.events("started")[0]?.pid;
h.stop();
const gone = await waitFor(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, scale(5000));
r.check("closing the pane ends the agent process", gone, `agent pid ${pid} still running ${scale(5000) / 1000} s later; list it in limits if that's expected`);
if (!gone) try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
return r;
},
async "8"() {
const r = result("8", "Unknown requests from the agent get -32601, and the runner carries on");
const h = await launch("8", { scenario: { unknownRequest: true } });
const answer = await waitFor(() => h.answerTo("fake/unknown_method"));
r.check("the unknown request is answered -32601", answer?.msg.error?.code === -32601, `answer: ${JSON.stringify(answer?.msg ?? null)}`);
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "still working?"));
r.check("the next prompt still goes through", await waitFor(() => h.prompts().length === 1), "no prompt after the unknown request");
return r;
},
async "9"() {
const r = result("9", "First input: OPENRIG_FIRST_INPUT's exact bytes are the first prompt");
if (!opts.firstInput) return r.skip("the preset doesn't declare first_input (--first-input): OpenRig passes no OPENRIG_FIRST_INPUT");
const dir = mkdtempSync(join(tmpdir(), "runner-check-first-"));
const file = join(dir, "first-input");
const text = "Read the plan first.\n\n Then say ready.\n";
writeFileSync(file, text, { mode: 0o600 });
const h = await launch("9", { env: { OPENRIG_FIRST_INPUT: file } });
const first = await waitFor(() => h.prompts()[0]);
r.check("the first prompt is the file's bytes, newlines and spaces included", first === text, `got ${JSON.stringify(first)}`);
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
return r;
},
};
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
const selected = opts.cases ?? order;
const unknown = selected.filter((id) => !order.includes(id));
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
let results;
try {
results = await Promise.all(order.filter((id) => selected.includes(id)).map(async (id) => {
try { return await cases[id](); } catch (error) { return { id, title: id, status: "fail", checks: [{ name: "the case ran", ok: false, detail: error.message }] }; }
}));
} finally {
tmux("kill-server");
server.close();
// A runner or agent can still be writing its last lines as its pane closes, so cleanup waits a moment and never fails the report.
await sleep(scale(300));
if (!opts.keep) for (const h of launches) try { rmSync(h.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* left in the temp folder */ }
}
// A proposed duty's result is advisory: it says whether the runner met it, outside pass, fail and the exit status.
for (const r of results) if (r.proposed && r.status !== "skip") { r.met = r.status === "pass"; r.status = "advisory"; }
const summary = Object.fromEntries(["pass", "fail", "skip", "advisory"].map((s) => [s, results.filter((r) => r.status === s).length]));
if (opts.json) {
console.log(JSON.stringify({ check: "openrig-runner-conformance", format: 1, runner: opts.runner, name: opts.name, results, summary }, null, 2));
} else {
console.log(`OpenRig runner conformance check (docs/reference/harness-runner-contract.md)\nrunner: ${opts.runner}\npreset: ${opts.name}\n`);
for (const r of results) {
const label = r.status === "advisory" ? "NOTE" : r.status.toUpperCase();
const outcome = r.status === "advisory" ? (r.met ? ": met" : ": not met") : r.reason ? `: ${r.reason}` : "";
console.log(`${label.padEnd(5)} ${r.id.padEnd(3)} ${r.title}${outcome}`);
for (const c of r.checks.filter((c) => !c.ok)) console.log(` x ${c.name}: ${c.detail}`);
}
console.log(`\n${summary.pass} pass, ${summary.fail} fail, ${summary.skip} skipped, ${summary.advisory} advisory (not counted)${opts.keep ? `; launch folders kept under ${tmpdir()}` : ""}`);
}
process.exit(summary.fail ? 1 : 0);
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// A scriptable fake ACP agent for the runner conformance check (check-runner.mjs). It speaks just enough of ACP v1 over
// stdio, newline-delimited JSON-RPC, to exercise a runner without a model, credentials or the network. It was written
// after the fake agent in bpamiri/dsh-seat (Apache-2.0), which showed the approach.
//
// node fake-acp-agent.mjs --scenario <file.json> --log <file.jsonl> [any arguments the runner adds are ignored]
//
// The scenario says how to answer; the log records every message in and out, so the check can compare them.
// sessionId the id `session/new` returns (default "fake-session-1")
// initialize "error" or "exit" to fail the handshake there
// newSession "error" or "exit" to fail `session/new`
// newDelayMs wait this long before answering `session/new`
// knownSessions ids `session/load` and `session/resume` reopen
// reopenError "missing" (the agent's own "no such session") or "error", for an id it doesn't know
// unknownRequest send the runner a request with a method it can't know, once, after `initialize`
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway, when
// holdMs runs out or, with endAfterCancelMs, that long after the first cancel if that's sooner
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
// waitMs stop waiting for the answer after this long, and end the turn
// exitAfterMs exit with status 3 this long after asking, while the request waits
// A `session/cancel` while the request waits ends the turn "cancelled" once it's answered.
// error answer the prompt with a JSON-RPC error
// exit exit with status 3 instead of answering
// stopReason the stop reason to return (default "end_turn")
import { appendFileSync, readFileSync } from "node:fs";
import { createInterface } from "node:readline";
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; };
const scenarioFile = arg("--scenario") ?? process.env.RUNNER_CHECK_SCENARIO;
const logFile = arg("--log") ?? process.env.RUNNER_CHECK_AGENT_LOG;
const scenario = scenarioFile ? JSON.parse(readFileSync(scenarioFile, "utf8")) : {};
const log = (entry) => { if (logFile) appendFileSync(logFile, `${JSON.stringify({ at: Date.now(), ...entry })}\n`); };
const send = (msg) => { log({ dir: "out", msg }); process.stdout.write(`${JSON.stringify(msg)}\n`); };
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
const exit = (status, why) => { log({ dir: "event", event: "exit", status, why }); process.exit(status); };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2) });
// Ctrl-C is the runner's to handle, so the agent never takes it as a signal. A stubborn agent also outlives a hangup and
// its input closing, as some real agents do: only the runner ending it (SIGTERM or SIGKILL) stops it.
process.on("SIGINT", () => log({ dir: "event", event: "signal", signal: "SIGINT" }));
process.on("SIGHUP", () => scenario.stubborn ? log({ dir: "event", event: "signal", signal: "SIGHUP" }) : exit(0, "SIGHUP"));
process.on("SIGTERM", () => exit(0, "SIGTERM"));
let sessionId;
let turn = 0;
let nextId = 1;
const answers = new Map();
let cancelTurn = null;
// Numbers, as JSON-RPC agents commonly use. A runner passes one on unchanged, and OpenRig reads it as its decimal string.
function ask(method, params) {
const id = 1000 + nextId++;
send({ jsonrpc: "2.0", id, method, params });
return new Promise((resolve) => answers.set(id, resolve));
}
async function prompt(id, params) {
const spec = (scenario.turns ?? [])[Math.min(turn, (scenario.turns?.length ?? 1) - 1)] ?? {};
turn += 1;
if (spec.exit) exit(3, `turn ${turn}`);
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
let stopReason = spec.stopReason ?? "end_turn";
if (spec.permission) {
const p = typeof spec.permission === "object" ? spec.permission : {};
const asked = ask("session/request_permission", {
sessionId: params.sessionId,
toolCall: { toolCallId: `call-${turn}`, title: "Edit notes.txt", kind: "edit" },
options: p.options ?? [
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
],
});
if (p.exitAfterMs) setTimeout(() => exit(3, "while a permission request waits"), p.exitAfterMs);
let cancelled = false;
cancelTurn = () => { cancelled = true; };
const answer = await (p.waitMs ? Promise.race([asked, sleep(p.waitMs).then(() => null)]) : asked);
cancelTurn = null;
if (!answer) log({ dir: "event", event: "permission-wait-ended" });
if (cancelled) { reply(id, { stopReason: "cancelled" }); return; }
}
if (spec.holdMs) {
const cancelled = await new Promise((resolve) => {
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
cancelTurn = () => {
if (!spec.ignoreCancel) { clearTimeout(timer); cancelTurn = null; resolve(true); return; }
if (spec.endAfterCancelMs === undefined) return;
cancelTurn = null;
setTimeout(() => { clearTimeout(timer); resolve(false); }, spec.endAfterCancelMs);
};
});
if (cancelled) stopReason = "cancelled";
}
reply(id, { stopReason });
}
async function reopen(id, params) {
if ((scenario.knownSessions ?? []).includes(params.sessionId)) { sessionId = params.sessionId; reply(id, null); return; }
if (scenario.reopenError === "missing") fail(id, -32002, `Session not found: ${params.sessionId}`);
else fail(id, -32603, "fake agent: reopen failed");
}
async function handle(msg) {
if (msg.id !== undefined && msg.method === undefined) { answers.get(msg.id)?.(msg); answers.delete(msg.id); return; }
switch (msg.method) {
case "initialize":
if (scenario.initialize === "exit") exit(2, "initialize");
if (scenario.initialize === "error") { fail(msg.id, -32603, "fake agent: initialize fails"); return; }
reply(msg.id, { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] });
if (scenario.unknownRequest) await ask("fake/unknown_method", { note: "a method no runner knows" });
return;
case "session/new":
if (scenario.newDelayMs) await sleep(scenario.newDelayMs);
if (scenario.newSession === "exit") exit(2, "session/new");
if (scenario.newSession === "error") { fail(msg.id, -32603, "fake agent: session/new fails"); return; }
sessionId = scenario.sessionId ?? "fake-session-1";
reply(msg.id, { sessionId });
return;
case "session/load":
case "session/resume":
await reopen(msg.id, msg.params ?? {});
return;
case "session/prompt":
await prompt(msg.id, msg.params ?? {});
return;
case "session/cancel":
cancelTurn?.();
return;
default:
if (msg.id !== undefined) fail(msg.id, -32601, `Method not found: ${msg.method}`);
}
}
const lines = createInterface({ input: process.stdin });
lines.on("line", (line) => {
let msg;
try { msg = JSON.parse(line); } catch { log({ dir: "event", event: "garbage", line }); return; }
log({ dir: "in", msg });
void handle(msg);
});
lines.on("close", () => { if (scenario.stubborn) { log({ dir: "event", event: "stdin-closed" }); setInterval(() => {}, 60_000); } else exit(0, "stdin closed"); });
+28 -3
View File
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -165,6 +182,12 @@ commit. Evidence capture (`proof add`), policy acceptance, higher outcome
judgment and publication are distinct. This optional component does not select
Part B or add a mandatory review gate.
A judgment binds the slice's path, the item's text and the policy. Moving or
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
current revision. `rig scope audit` checks structure only and never reads proof
readiness, so a passing audit doesn't mean the proof still stands.
The project readiness that `rig proof show --project` reports counts only
missions whose `mission.yaml` carries `metadata.status: active` or
`metadata.status: release-candidate`. `rig scope mission create` writes
@@ -172,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
+130 -7
View File
@@ -10,22 +10,100 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
`--dangerously-interact --reason` remains available at every interruption level
and uses the existing-input check below. Direct human input remains available.
A message sent to a busy Codex seat joins its running turn and may redirect it.
For separate work, use `rig send <seat> "<message>" --wait-for-idle <seconds>`.
For separate work, create a queue row instead:
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
A row that's saved keeps the work apart from its wake, the short pointer typed
into the seat. The wake is still typed input, and it isn't proof that the seat
claimed or finished the work: the row's state says that.
`rig send <seat> "<message>" --wait-for-idle <seconds>` suits a sender who
watches the result. If the seat is still busy, or its activity is unknown, when
the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps
nothing to send later.
## Configure and inspect
### Latest permission observation
`rig seat status <seat>` can show the latest observed harness permission request,
with a fixed tool descriptor, source time and actor category. This is one latest
observation across actors, not an aggregate of pending prompts. `request_observed`
leaves the displayed actor's pending state unknown. `completed` applies only to
that actor's later tool batch; other actors' pending state is still unknown. An
unavailable observation proves neither a permission stop nor the absence of a
waiting native prompt. With no observation, human status omits this section;
JSON keeps the explicit unavailable state. The JSON field is `permission_stop`,
and its `pending` value has the same observation-only scope.
An observation may also carry Claude's **suggested rules** as `suggested_rules`.
These are read-only evidence, not applied or approved, not guaranteed effective,
not the exact command, and not a complete list of dialog options. The supported
subset of [Claude's permission update schema](https://code.claude.com/docs/en/hooks#permission-update-entries)
is `addRules`, `replaceRules` and `removeRules`, preserving type, ordered rules
(`toolName`, optional `ruleContent`), behavior and destination exactly. Strings
are neither trimmed nor broadened; absent `ruleContent` remains distinct from
an empty string. Mode/directory updates and malformed entries are withheld.
The envelope distinguishes a missing native field from a present empty array;
`unsupported` and `size_cut` mark incomplete evidence. An older relay without
this field, or a failed formatter, is unavailable rather than proof of native
absence. Bounds are eight entries, sixteen rules per entry, 128 UTF-16 code
units per tool name, 2,048 per rule-content string, and 16,384 UTF-8 bytes of
serialized evidence. Oversized whole entries are dropped with `size_cut`,
never shortened into different plausible rules. Both producer and ingress
validate these bounds independently. Terminal JSON escapes controls and
nonprinting format/separator characters while preserving decoded string values;
its representation can expand, so the byte bound applies to stored/transported
evidence, not the entire formatted status output.
**Suggested rule strings can contain sensitive values.** The relay sends only
this bounded rule subset alongside the fixed descriptor. Rule content can itself
contain sensitive command-like text; raw `tool_input`, arguments outside that
suggestion, and provider reasons are not forwarded. It posts to `/api/activity/hooks` at the
configured `OPENRIG_URL` (or host/port), or the discovered endpoint in
`OPENRIG_HOME/activity-endpoint.json`. Both `GET /api/seat/status/:seatRef` and
`GET /api/seat/status/node/:nodeId` can return the evidence. The daemon adds no
event, log, queue or telemetry persistence for it; a caller can still capture
status output in a transcript or file. Evidence shares the observation's
five-minute lifetime and existing seat/actor limits, is discarded lazily on the
next observation/status read after expiry, and is lost on restart. No rule,
permission/configuration write, hook decision, edited input, prompt submission
or new attention/delivery behavior is produced.
Actor attribution is a convention derived from the [Claude hook schema](https://code.claude.com/docs/en/hooks#common-input-fields): an omitted or null `agent_id`
is treated as main, a valid ID identifies a subagent or in-process teammate, and
an invalid ID is dropped rather than inferred as main. The fixtures are synthetic;
no live PermissionRequest capture qualifies that convention on an installed native
version. A hook denial without a source-bound guard receipt has unknown origin.
This diagnostic adds no guard collector, permission decision or attention policy.
The new PermissionRequest diagnostic is optional for activity-hook readiness.
Every preexisting required event, relay asset and explicit hook-disable check
still applies. An older installed plugin at the same manifest version retains
its bytes and may not send this event: plugin-only delivery needs a future normal
maintainer release refresh, and managed delivery needs normal reprojection. A
manifest or green readiness check does not prove live hook execution. Raw tool
arguments and provider reason text are withheld; diagnostic evidence is bounded,
memory-only and expires after five minutes.
### Who may intervene
`rig seat status <seat>` shows the seat's terminal interaction account alongside
@@ -96,8 +174,18 @@ their existing behaviour.
### Terminal delivery
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
seat's shell. The daemon takes the recorded actor from transport identity only:
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
its audit entry name that seat.
- From a terminal with no seat identity, the change applies the same way, and
the preference and its audit entry record the actor as
`unattributed (no seat identity)`.
Neither case takes an actor from the request body or from the target seat's name.
`rig seat retire-held-message` still records the seat that retired a message, so
run it from a seat's shell.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
@@ -116,8 +204,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -135,6 +236,26 @@ false` and the protection reason, rather than claiming durable custody.
Reading never sends. Retirement frees that record's active retention quota while
preserving evidence; it does not deliver a message or close queue work.
A seat holds one message per sender, so one sender can't fill its held quota and
drop everyone else's messages. A sender's newest held message retires its earlier
ones at that seat into itself (`retired_by: outbox@system`, reason `coalesced into
<id>`), in every mode, `draft-aware` included. The newest names them: `rig seat
held-messages` shows `coalesced: { count, outboxIds }` on it, and each coalesced
record stays readable with `--id`. Held messages are never replayed, so this changes
no delivery. A `draft-aware` attempt takes its sender's place only once it settles
as held; one that is then delivered leaves the held messages as they are. A queue
wake to a `draft-aware` seat counts its sender's held message there as room, by the
same rule. At a full quota, a sender with nothing held there is still refused, and
nothing from another sender is touched. A committed wake is never refused, and the
sender's next held message coalesces it like any other. A message that settles as
held late, behind a newer held send, folds into that send's newest part and leaves
its other parts held. An urgent message coalesced under a routine one passes its
marking to the newest. Never coalesced: the other parts of the same send, a
watchdog's reminders (one per job), and an unattributed sender.
A backlog held before this change clears at that sender's next held message to the
seat, or at once with `rig seat retire-held-message`.
## Input evidence and outcomes
The cursor-aware reader lives in `session-transport.ts` and shares the composer
@@ -215,7 +336,9 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
present, is the recorded actor; without it the change is recorded as
`unattributed (no seat identity)`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
+17
View File
@@ -0,0 +1,17 @@
# Seat settings by node ID
Clients that present a specific seat can save its later-launch settings using its durable `nodeId`. The node ID is distinct from the current session and native conversation. These routes never launch, stop, or resume the seat.
| Read or save | Node-addressed route |
| --- | --- |
| Status | `GET /api/seat/status/node/:nodeId` |
| Native permissions | `POST /api/seat/set-permissions/node/:nodeId` |
| Permission policy | `POST /api/seat/set-policy/node/:nodeId` |
| Model | `POST /api/seat/set-model/node/:nodeId` |
| Effort | `POST /api/seat/set-effort/node/:nodeId` |
The POST bodies and responses follow the corresponding name-addressed routes. A successful save's `seat.nodeId` and `seat.rigId` identify the node actually updated. The exact status response adds `node_id`, `contract: "seat-node.v1"`, and `supported_actions` listing the four setters. Existing name status responses retain their shape.
An unknown, deleted, or archived node returns `seat_not_found` (HTTP 404) at node resolution. After awaited validation of a native Claude permission mode or Codex profile returns, the exact node-ID path checks availability again before saving; an unavailable node then returns `seat_not_found` without a setting or audit write. Existing native validation may refuse a changed target before this check: for example, Claude's managed context guard can report `permission_selection_refused` during preparation. The daemon never resolves a node ID through a reusable seat name. Setting writes have no general session precondition; a changed occupant on the same available node can receive a later save.
For example, `POST /api/seat/set-model/node/<nodeId>` accepts the existing `{ "model": "...", "reason": "...", "operator": "..." }` body. The existing audit actor, reason, native validation, permission precedence, and terminal policy rules still apply. The older `/:seatRef` routes remain available, including their name ambiguity behavior.
+84 -9
View File
@@ -58,6 +58,15 @@ not fill in; if so, follow the form.
one, it is refused.
10. Run `rig slack setup --channel <channel-id> --secrets-env-file <path>`, then
`rig slack verify`, then `rig slack enable`.
11. Enabling treats the asks already open as seen, so none of them post. To see them in Slack, run
`rig slack post-open-asks`. Each goes through ordinary delivery at the level it was asked at
(quiet hours, digest and the posting floor apply), and the command says what became of each:
posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer
active, superseded, recipient changed, state unavailable, transport failed or uncertain. An ask
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
## What the app asks for
@@ -90,8 +99,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -100,6 +125,14 @@ reaction on any other message, including a person's reply in the thread, is igno
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
reinstall the app to the workspace.
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
back with `--reactions route` needs no change to the app. Like any saved change, the setting reaches
a running connector when it next rewires, which a daemon restart does (`rig daemon stop`, then
`rig daemon start`). See [One channel per rig or seat](#one-channel-per-rig-or-seat-optional) for
why disabling and re-enabling the connector isn't the same.
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
clicks arrive over the same socket, so no request URL is needed. An app created from an older
@@ -149,6 +182,9 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -182,7 +218,15 @@ rig slack channel-map remove pr@my-rig
- **Answers come back to where the person asked.** A person's new message is the root of its
answer: when the seat it reached replies with `--reply-to` that message's row, the answer and
later updates land in the person's thread, in the channel they used.
- Missed-message recovery (below) covers the default channel only.
- **One conversation, whoever writes.** Messages routed to the same continuing agent
conversation can use that conversation's earlier context, whoever sent them. A queue row
records who sent a message and which seat owns it; it doesn't start a separate agent
conversation. If people need separate conversations, route them to separate seats
(`--inbound` names the seat a channel's new messages reach). Separate channels help only when
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
membership in the default channel and every mapped channel, and reports NOT ready until the
@@ -190,13 +234,15 @@ app is in all of them. A saved change reaches a running connector when it next r
daemon restart does (`rig daemon stop`, then `rig daemon start`). `rig slack disable --reason
"<why>"` then `rig slack enable` rewires too, but alerts still waiting to be posted when it's
re-enabled are treated as already seen and aren't posted. Only an earlier post that failed or
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. The TUI's Connections page says whether the running
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. `rig slack post-open-asks`
then shows the asks still open. The TUI's Connections page says whether the running
configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
## Named bots (optional)
@@ -258,7 +304,7 @@ reaches a running connector when it next rewires, which a daemon restart does (`
then `rig daemon start`). `rig slack disable --reason "<why>"` then `rig slack enable` rewires too,
but alerts still waiting to be posted when it's re-enabled are treated as already seen and aren't
posted. Only an earlier post that failed or didn't finish still posts: an alert whose row is still
open, a decision-resolved notice, or a digest.
open, a decision-resolved notice, or a digest. `rig slack post-open-asks` then shows the asks still open.
## One app across machines (optional)
@@ -303,8 +349,7 @@ decisions, and posts its own replies.
- **Changing forwarding entries is not a drain or cancellation:** relays already retained keep
their original host and remain visible in `rig slack status`, even after the last entry is
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
prove they have drained: it does not count relay HTTP requests still in flight.
Let relays finish before removing a forward entry; `rig slack status` shows local in-flight attempts separately from retained records, but zero does not prove global quiescence.
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
it with `host.name`).
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
@@ -340,6 +385,36 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
## Reconnect recovery and status
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
+64
View File
@@ -115,3 +115,67 @@ successful empty history. The CLI preserves the error body in JSON mode and exit
1 for request errors, 2 for unavailable reads. Existing SSE and usage APIs retain
their behavior. Collector deployment, exporters and durable store epochs are
separate work.
## Independent exporter mapping and limits
OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope above. Any
projection of those finite reads (or of cached rig/node inventory) into
OpenTelemetry logs, custom metrics, or spans is defined and owned by an external
consumer or exporter—not guaranteed by OpenRig's core API.
The external OTLP exporter at `smota/openrig-telemetry` applies the following
exporter-specific mapping choices to OpenRig's source facts (other consumers may
make different choices):
- **Recorded queue-state intervals (not execution time)**: In OpenRig, queue
transition rows record state changes (`pending`, `in-progress`, `blocked`, and
departure states), not agent execution duration or model latency; the first
observation of an item has no prior boundary; repeated same-state rows remain
distinct source records; and any page carrying an ID-range or withheld-record gap
(`before_retained_floor`, `retained_sequence_gap`, `conflicting_transition_id`,
`record_byte_limit`, `historical_transitions_not_read`, truncated gap details, or
a boot/watermark discontinuity) cannot prove what happened in the missing range.
In `smota/openrig-telemetry`, the exporter pairs an entering state with a later
departure state to measure only the recorded interval between two observed rows
with valid, ordered UTC timestamps, suppresses or drops open intervals across gap
boundaries rather than inventing durations, and reports cumulative transition
counts only for rows observed from its checkpoint (including historical rows when
started with `start=retained`), never lifetime daemon totals.
- **No current-owner, seat, tenure, or native-session join**: In OpenRig's finite
API, the `queue-transitions` stream does not join transition rows to current seat
owners, mutable node or rig names, occupant tenures (`tenures`), or native
harness sessions, and it never selects queue bodies or transition notes; each
transition row does include `qitemId`, `actorSession`, `closureTarget`, and
`originalTimestamp`. In its exported OTLP output, `smota/openrig-telemetry`
chooses to emit derived queue spans as root/internal observations without
invented parent/child trace hierarchy, cross-stream causal links, or `gen_ai.*`
model/token/cost attributes; places `qitemId` only on opt-in log/span attributes
rather than metric dimensions; and omits the source fields `actorSession`,
`closureTarget`, and raw `originalTimestamp` from exported OTLP logs, metrics,
and spans.
- **Sampling unknowns on cached seat inventory**: OpenRig's cached rig and seat
inventory endpoints (`GET /api/rigs`, `GET /api/rigs?archived=only`,
`GET /api/rigs/:rigId/nodes`) return point-in-time daemon projections
(`activityState.display`, `sessionStatus`, `arbitratedNeedsInput`, and
`activityState.lastSwap`), not a continuous event stream or independent OS
process probe; intermediate activity changes or multiple occupant handovers
between polls are not observable from those snapshots, and initial
observation has no backfilled history. In OpenRig source,
`arbitratedNeedsInput` is a tri-state boolean (`true`, `false`, or `null`):
`true` means the seat needs input, `false` means it was observed clear of
needing input, and `null` means no evidence exists either way (it does not
indicate `false`). Separately, external consumers or metric mappings must
choose how to interpret or project this tri-state value into their own
metrics or counters. `smota/openrig-telemetry` polls these snapshots as
point-in-time observations and relies on downstream metric storage to enforce
freshness/staleness TTL when polling stops or a seat departs.
### External OTLP exporter repository and version compatibility
An independent, standalone OTLP/HTTP exporter is maintained outside OpenRig core at:
- **Repository**: <https://github.com/smota/openrig-telemetry>
Its repository documents installation, configuration, and supported OpenRig versions; OpenRig does not test or guarantee the exporter.
OpenRig's finite telemetry reads are versioned (`schemaVersion: 1`), while cached rig and node inventory endpoints are operational projections rather than a versioned telemetry contract.
+373 -8
View File
@@ -30,25 +30,53 @@ With neither a provider nor a session, the daemon's open and preview APIs try
a live Herdr first, then a live cmux. An explicit session always selects Herdr
and skips that fallback, including when the daemon's default Herdr endpoint is down.
An explicit session resolves under the daemon user's Herdr session directory;
it takes precedence over that daemon's default socket override. The selection
belongs to the request. Saved views still describe membership and layout, so
pass `--session` again when reopening the view in that session.
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
when unset. Relative paths (including an empty value) resolve from the daemon's
startup directory, matching Herdr's configuration selection. Named sessions live under
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
These settings are captured when the daemon starts.
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
configuration-tree path, without reusing the override or inferring a session
from an arbitrary socket path. With an explicit socket but no configured
session name, omit `--session` to keep that exact endpoint. A missing selected
socket never falls back to another session. Viewer and follow operations carry
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
CLI `--session` that would override it. They check that the CLI reports the
selected socket before changing its panes. Daemon-side CLI calls retain the
startup environment; desktop follow uses the socket confirmed by the daemon.
The selection belongs to the request. Saved views still describe membership
and layout, so pass `--session` again when reopening the view in that session.
The existing view rules apply: saved views, rig and pod views, read-only mission
and slice views, pagination, absent seats and partial results all use the same
composer. Selecting a session changes where those tiles are placed. It does
not relaunch the seats' tmux sessions or move queue ownership.
## Open results
For Herdr, a successful layout request alone does not count its seats as opened.
OpenRig checks the returned tab's pane list. A missing tab identity or an
unreadable, empty or unattributable listing puts the affected seats in
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
the layout may have taken effect even though its panes could not be confirmed.
Confirmed seats on other pages still count; with none confirmed, the command
returns a nonzero exit status. Reusing an existing workspace keeps its separate
reuse result. This pane check does not prove desktop visibility or create a
verified viewer mapping.
## Replacing a seat's session
New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then reattach with the original read-only setting. This uses the shared tile
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
handover and restart keep the existing attachment.
then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
A deliberate detach closes the tile after about two seconds. A different seat
Detaching a tile you typed in returns it to showing the seat. A different seat
under the old name is never followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
@@ -139,3 +167,340 @@ session keeps the normal HTTP 200 provider result with `ok: false`, its selected
`session`, an error code and direct-attach guidance.
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
reports that known failure instead of replacing it with an unconfirmed-session error.
## Task terminal views
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
view of the current owners of a local queue episode. The episode includes the
requested row and explicitly linked descendants; only pending, in-progress and
blocked obligations contribute owners. It does not include historical senders,
closed predecessors, parent episodes or siblings. Membership is not input
permission.
The existing daemon endpoints accept the same view:
```text
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {kind:"qitem", id, qitemId, name, complete, issues, members}`. Each member
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
external and unresolved remote owners remain visible. Remote queues are not
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
explicitly incomplete; it is never silently presented as the whole team.
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
It reads one snapshot from the requested task forward, then probes earlier active
obligations using the queue's existing state index and follows references by exact
ID, including closed intermediaries. A child may arrive before its referenced root.
All these reads share the work limits; unrelated older closed history is not scanned.
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
are supported.
Hitting a work limit preserves known members and reports incomplete membership;
it does not claim that an owner outside the scanned history is absent.
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
derived views and `fixed` for a saved layout. A task uses its row summary as
its name, falling back to the qitem ID. The existing preview `view` string is
unchanged. A following view derives membership when requested; it does not
mean an open workspace updates itself.
Preview is read-only. A member without owned viewer provenance is
`not-provisioned`; the ensure operation below can establish a reusable provider
mapping. An explicit open retains the provider's existing workspace behavior;
it does not continuously reconcile the view or change an agent's lifecycle.
## Following one task
`rig terminal open qitem:<id> --follow` keeps one workspace following the
task's current obligation owners. A joining member gets a verified local
viewer tile without taking focus. A departing member loses only its viewer,
after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
Live following supports **Linux and macOS with Herdr protocol 22**. Admission
checks the live protocol and selected socket, then confirms each pane's IDs,
focus state and foreground attachment. A shell startup `cd` does not change
pane identity; filesystem aliases of the same existing path are accepted.
If a creation response is lost and shell startup changes the directory, Core
may be unable to attribute that pane. It reports uncertainty and does not
repeat the creation.
The detected Herdr version remains visible in provider status. The native
qualification version is 0.9.5; a matching release label alone is not proof of
compatibility, and future protocol numbers are not assumed compatible.
If a new view's platform or protocol cannot support following, Core opens a
labelled **snapshot** instead and disables automatic following. It will not
track later owner changes. Incomplete membership prevents that snapshot from
opening; the result explains why. A failed endpoint/identity read or an existing
canvas stays unconfirmed, preserving it instead of creating a fallback view.
Other providers and selector kinds retain ordinary one-shot open. Use
`--provider herdr --json` to operate without opening a desktop window, and
`--session <name>` to select that endpoint. Typing in a tile can change the
agent's terminal size while you type.
```bash
rig terminal open qitem:example --follow --provider herdr --json
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
rig terminal open qitem:example --no-follow
```
`--unpin` and `--unsuppress` reverse those presentation choices. The default
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
exceed the cap rather than being evicted. A departed tile retained for typed or
unconfirmed input does not consume a slot for joining members. Disabling removes
the saved following selector and keeps the existing layout intact.
The default departure grace is four seconds (`--grace-ms`). A focused leaver
gets another twelve seconds for reading (`--focus-retention-ms`); before its
tile closes, focus moves to a verified surviving member, or to the explanation
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
as no longer participating. Explicit suppression also retains a typed draft.
Pi, terminal and community harness input may be unreadable to the composer
inspector: those tiles remain while input is unconfirmed, even after unpinning.
Each input-retained departure gets one delayed recheck after thirty seconds.
After that, queue or node/session lifecycle events, or an explicit repeat of
the follow command, can reconsider it. Activity hooks do not wake following,
and input is not repeatedly polled. Draft text is never saved.
Following consumes Core events and one-shot retention/suppression deadlines,
not repeated CLI membership queries. Complete empty membership drains the
tiles and preserves the workspace. Once no retained tiles remain, following
stops and the selector releases its active slot; new work does not automatically
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
or changed pane/terminal generation retains the previous view and reports
the uncertainty. It never authorizes a blind workspace rebuild or close.
`rig terminal views --json` and `rig terminal status --json` include the saved
active selectors and latest following status. Active selectors and owned viewer provenance
survive daemon restarts; task membership is always re-derived.
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
Seat controls are arrays of names from the current/retained view. It uses the
existing terminal bearer boundary, like ensure-viewer. The response includes
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
provenance grants no membership or input authority. This is a local native
viewer operation; browser input and remote-host provisioning are separate.
## Ensuring an owner viewer
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
expectedPlan}`. Use a current preview's member key and plan. This explicit
operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On a supported Linux or macOS Herdr protocol-22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. Each viewer's
private `attach.sh` is kept for provider restarts. `--no-follow` preserves the
workspace and its scripts; stopping Herdr is not proof that its panes are gone.
Core removes only an exact, unedited owned script after confirming its viewer
is absent. Old views that are never reconciled again can therefore accumulate
scripts; there is no cleanup based only on an unreachable provider.
A lost create reply is reconciled before starting anything else; a lost run
reply is checked against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
viewer status. Studio can ensure a new member, add its browser frame, and retain
a pinned leaver labeled as no longer participating. The provider mapping is
small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
A tmux window has one size, and a tmux client narrower than it sees only the
left part of each line. So a tile shows its seat without attaching to it: about
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
after tmux records new activity, and lets the tile's own terminal wrap each
line. A tile narrower than the agent's window still shows whole lines, and
showing a seat never changes its size. A rule drawn across the window is cut to
the tile's width. A read-only tile only shows the seat. In a writable tile, the
first thing you type or paste attaches the tile and goes through tmux as it
would in any attached client: a paste arrives as one paste, and tmux's prefix
works (prefix then `[` to scroll back, for example). The tile sizes the window
while you use it, as any client you type in does; after 30 seconds without a
key it detaches and shows the seat again. A tile that's only showing has no
scrollback of its own. If the seat's tmux server restarts, a tile shows the
seat again only when the same managed seat returns. The printed manual attach commands
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
it; a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
Use `rig terminal open slice:<name-or-id> --follow --provider herdr` to keep
one workspace following the current destination owners of that slice's active
queue rows. `pending`, `in-progress` and `blocked` rows count. A member can own
several rows; `task.members[].obligations` preserves every row and state.
A slice resolves within the daemon's configured slice index, by exact directory
name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
replaced; merely linking an untagged child does not associate it. A mission-only
tag does not put a row in every slice. No project is inferred from a tag or the
caller's working directory. A bare name or ID claimed by multiple slices is
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
still follow a slice whose other alias is reused: a row carrying that colliding
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
Rows placed in another mission are excluded. Missing, contradictory or otherwise
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
viewers. A unique alias on its own retains exact matching. Flat legacy slices
without an indexed parent mission cannot disambiguate a colliding alias.
Mission views use `mission:<directory-id>`, from the same configured index.
A mission is known when its directory contains `slices/` (even empty) or a
readable `mission.yaml` declaration. Its directory ID is its exact identity;
mission frontmatter is not an alias. Membership is the union of active rows
with that exact `mission:` tag and rows associated with any physically indexed
child slice by its exact name or unique ID. Each row counts once even when it
has both tags. Flat legacy slices without that parent association are not
inferred into a mission. A child's historical status or composition activation
flag does not remove its current obligations from the union. Duplicate mission
IDs are ambiguous. Shared child names/IDs are omitted from bare slice matching;
rows with one indexed claimant mission are placed through their mission tag.
A direct mission tag takes precedence, including work tagged to both missions.
Without that direct tag, a shared-key row with missing, contradictory or
non-claimant mission tags is named in `issues` and makes the snapshot incomplete;
known owners remain.
The launcher reads this same terminal preview and submits its `expectedPlan`
when opening, rather than using the review-agent roster.
Slice and mission keys are case-sensitive and can include Unicode, plus signs and internal
spaces. They cannot be empty or contain slash, backslash, control characters,
or leading/trailing whitespace in the scope reader. The service trims the whole
selector before composition, so a trailing selector space is accepted by
preview/ensure with the same plan; ensure rejects a leading selector space.
Prefer the exact canonical selector in every call. Pass it in JSON or a quoted
CLI argument; encode it once as a GET query value. Do not double-decode it.
Slice previews and open results use `task: {kind:"slice", id, name, complete,
issues, members}`. `id` is the unique frontmatter id, or the directory name when
no id is present. There is no synthetic `qitemId`. Preserve the preview's `view`
for the ensure request. All successful previews, including qitem previews,
include `capabilities: {ensureViewer:boolean}`. True means that the resolved
view/provider implements ensure; the existing member, completeness, owner and
`expectedPlan` checks still apply. A missing capability/task on an older daemon
is unsupported scoped membership, not an empty team.
Mission results use the same shape with `task.kind:"mission"`; `task.id` and
`task.name` are the directory ID, with no synthetic `qitemId`. Ordinary mission
preview/open uses this current-obligation membership too: past participants
without active obligations are excluded, while unavailable current owners
remain named. Follow, preview and ensure share the same authority.
New slice and mission workspaces default to four automatic tiles; `--max-tiles` overrides
that. Qitem views retain their eight-tile default. The full membership and
obligation list remains available when tiles exceed capacity. Pins, drafts,
unknown input and focus retention still protect departed viewers. Closing a
viewer never stops its agent. Opening a tile can change the agent's dimensions.
A known empty slice or mission remains enabled in the same workspace; later associated
work can join it. An incomplete queue read (`task.complete:false` plus `issues`)
retains previous viewers. A missing scope is `scope_not_found`, duplicate scope
identity is `scope_ambiguous`, unreadable/capped index data is `scope_unavailable`,
and an unavailable resolver is `scope_unsupported`. None is authoritative empty.
Ensure of an incomplete snapshot returns `viewer_unconfirmed`; changed plans
return `preview_changed` before provisioning.
Ordinary ownership changes follow queue/session/node events. Node and directory
changes under the primary configured index use the existing filesystem watcher,
with a coalesced scope invalidation event. There is no repeating membership
poll. Structural edits under additional legacy roots, or a filesystem where
watching is unavailable, require `rig`'s existing slice refresh endpoint
`POST /api/slices/refresh` or reopening the view. Missing or unreadable sources
keep prior viewers; they never certify that the work disappeared.
Scope identity reads are bounded to 4,096 directory entries, 16 KiB per selected
node-header read, and 8 MiB of source bytes per index generation. The byte budget
counts the bounded read, including text after short frontmatter. An index that
exceeds these limits is too large for this read; use a smaller configured index
or a qitem view. This is separate from unreadable or malformed sources.
Queue membership reads are bounded to 50,000 active rows plus one lookahead,
16 KiB of encoded tags per row and 8 MiB of aggregate encoded tags. They remain
fresh on each render. Identities are reused within a scope-source generation
only while the primary-root watcher is live and existing additional roots are
covered by that root. A source event or explicit refresh invalidates them;
errors discard the cache. Unavailable watching or an existing unwatched legacy
root uses fresh bounded identity reads instead. Missing legacy roots are checked
on each call so an appearing root cannot reuse an older cached identity set.
Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
An incomplete queue snapshot is a successful preview with `task.complete:false`;
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
ensure view kinds now return `view_required` (400), where they previously returned
200 with an error body. Unsupported follow view kinds
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
preview remains `preview_changed` (409). During automatic refresh a missing or
ambiguous scope retains the existing tiles and names the source problem in the
explanation strip; refresh or reopen it under the current unambiguous key.
## Reading or sending to an expected native conversation
An application that selects an original Claude Code or Codex conversation can opt
into an expected-identity check. The identity is the exact tuple `nodeId`, canonical
`sessionName`, `runtime` (`claude-code` or `codex`) and native `nativeId`. A registry
resume token by itself is not a current-process identity observation.
Read `GET /api/sessions/:sessionName/generation-record-expected` with query fields
`expectedNodeId`, `expectedRuntime`, `expectedNativeId` and optional `sinceBytes`.
It resolves the verified current occupant and native process, opens that native
record, checks its identity, and returns identity and bounded suffix bytes from
the same opened file. Its existing byte cursor and `generationId` semantics match
`generation-record`. Success adds `identityContract:1` and `checkedIdentity`, the
exact expected tuple. Check both before displaying the suffix as that conversation.
Send `POST /api/transport/send-expected` with the ordinary send body and a required
`expectedIdentity` object containing those four fields. The check runs again after
buffer preparation immediately before paste, and before Enter after any wait.
A retained input stores the constraint in its outbox receipt; committed dispatch
and reuse of its delivery ID cannot select a different original conversation or
omit the stored constraint. Delivery IDs still do not deduplicate explicit input.
Success adds `identityContract:1` and `checkedIdentity`. Retained is not sent;
`checkedIdentity` is an observation at an input boundary, not model consumption.
If replacement is observed after paste, the result reports `inputPhase:"written"`
and `sent:true`, with Enter refused. Inspect that input before deciding what to do.
Malformed expectations return 400 (`invalid_expected_identity`). A different
observed identity returns 409 (`expected_identity_mismatch`); a missing or
unverified identity returns 409 (`expected_identity_unavailable`). Conflicting
reuse of a delivery ID returns 409 (`delivery_identity_conflict`). No conditional
operation falls back to the replacement. Use these dedicated paths when detecting
support: older daemons return 404 instead of silently ignoring an unknown field.
Ordinary reads and sends without an expectation or an ID bound by an earlier
conditional send keep their existing behavior. Reusing a bound ID also carries
its stored constraint when the caller omits the expectation.
While a runtime switch is preparing or confirmed, a conditional send returns
409 `expected_identity_unavailable` with `sent:false` and `inputPhase:"not_reached"`.
It retains neither the input nor the expectation. Retry the conditional endpoint
with the original `expectedIdentity`; an ordinary send is not an identity-bound
retry unless its ID already has a stored constraint. Ordinary sends during the
switch keep their existing retained response.
This is a finite process/record observation followed by a terminal write; tmux has
no atomic native-conversation compare-and-write operation. A native conversation
change after the final observation remains a race. The check does not turn a
terminal attachment into a conversation pin: existing terminal viewers follow
managed pane/session bindings and may display their replacement. Applications
must keep that attachment limit separate from checked record reads and sends.
+214
View File
@@ -0,0 +1,214 @@
# Choosing the tmux server for an OpenRig instance
`terminal.tmux_server` controls where the daemon creates and reads managed seats.
It accepts `inherit` (the default) or `isolated`. The environment override is
`OPENRIG_TMUX_SERVER`.
To keep managed seats out of your personal `tmux ls`, set this before starting a
new instance, or before a deliberate restart of an existing daemon:
```sh
rig config set terminal.tmux_server isolated
```
The setting is read once at daemon startup. Changing it does not move a running
seat. Inspect the effective choice with:
```sh
rig terminal status --json
```
The `tmux` object reports `requestedMode` (read at the last startup), effective
`mode`, `socketPath`, `serverArgs`, `migrationPending`, and `blockingSessions`.
The settings browser shows the configured value; terminal status shows what the
running daemon actually uses.
## Server identity and configuration
In `inherit` mode, a new instance uses the launching shell's `TMUX` socket, or the
default socket under `TMUX_TMPDIR` (otherwise `/tmp`) when outside tmux. Native
commands retain tmux's existing environment-based selection. Printed attachments
and terminal metadata name a non-default server so a new terminal can reach it.
The default mode does not require a server record or an additional instance
identity check to start the daemon or use existing attachment paths.
In `isolated` mode, the socket is named `openrig-<hash>` under tmux's per-user
directory. The hash identifies the canonical `OPENRIG_HOME`: different homes get
different servers, and symlink aliases of one home share a server. Choose a short
`TMUX_TMPDIR` before the first isolated start if your normal temporary path would
exceed the platform's Unix socket path limit.
When isolation is requested or active, the daemon records the effective socket in
`OPENRIG_HOME/tmux-server.json` using an atomic, owner-only write. Keep this file
and the durable `tmux-server.initialized` marker with the instance state. A later shell
with different `TMUX` or `TMUX_TMPDIR` does not redirect an established isolated
instance. An invalid or unreadable isolation record is reported rather than overwritten.
If the record disappears while isolation is selected, startup and `destroy --all` stop before
touching sessions. Restore the record from an instance backup, or inspect the
running daemon's `rig terminal status --json` to recover its effective selection.
An ordinary inherited instance may record its socket for a later migration, but
missing, invalid or unwritable records do not prevent its normal startup.
The record's `pinned` flag distinguishes a fixed isolated or migration socket
from an advisory inherited record. Returning to ordinary inheritance commits
`pinned: false` and removes the active marker; an interrupted update must not
leave startup using one server and offline cleanup selecting another. A valid
unpinned record takes precedence over a leftover marker. Legacy inherited records
and the old `1` marker do not make ordinary inheritance depend on that file.
A newly started server loads tmux's normal system and user configuration,
including `~/.tmux.conf`; OpenRig does not substitute an empty configuration.
Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
If registered sessions still exist there, it keeps that server and reports a
pending migration. Explicit `OPENRIG_HOME` ownership markers distinguish another
instance's namesakes; unmarked legacy sessions are retained conservatively. If
the old server cannot be inspected, the daemon also keeps it and reports why.
Stop the listed sessions deliberately, then restart the daemon again to apply the
new selection. Merely editing the setting or waiting for the last seat to stop
does not switch a running daemon. The same rule applies when returning to
`inherit` from isolation. A normal inherited start continues to follow its launching
environment, as before. No
live pane is moved, restarted or killed by server selection. Old viewing windows
can be closed separately after their conversations have stopped.
Bring a stopped team back with `rig up <name> --existing`. Its normal restoration
rules still apply: a seat without usable provider history requires an explicit
fresh-start choice before it launches.
On the first upgrade from a version without a socket record, the old-server
candidate comes from the launching environment. If live seats use a non-default
server, start the upgraded daemon from that original server to establish its
record, or stop those seats before changing servers. Earlier versions did not
persist enough information to find an arbitrary old socket from a different shell.
## Moving an existing team onto tmux control mode
`OPENRIG_TMUX_CONTROL=1` in the daemon's environment sends its routine tmux commands
through one long-lived control-mode client instead of a new tmux client each time,
which saves a process start per command on a busy host. It takes effect only on a
tmux server at 3.7c or later. The version of the server that is running decides,
not the tmux binary on `PATH`, and a server keeps the version it started with until
it exits. So installing a newer tmux changes nothing for a server that is already up.
On an older server the daemon uses a spawned client for every command. It says so
once in its log (`[tmux] OPENRIG_TMUX_CONTROL=1 is set, but this daemon's tmux server
runs tmux 3.4 …`), and `rig doctor` reports it as `tmux_control_mode`.
To move a team that runs on an older server:
1. Install tmux 3.7c or later where the daemon finds it first on its `PATH`. For a
daemon run as a service, that's the service's environment.
2. Choose the server the team will use. `isolated` gives this OpenRig home a server of
its own, started by the daemon with that tmux, and leaves your own tmux sessions
alone: `rig config set terminal.tmux_server isolated`. In `inherit` mode the team
shares your default server, which runs the new version only after every session
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
`true`, `blockingSessions` names the sessions still on the old server.
5. Bring each rig back with `rig up <rig> --existing`. Each seat resumes its
conversation where its harness allows, under the restoration rules above.
6. Check: the daemon's log says `[tmux] Control mode is on`, and `rig doctor` passes
`tmux_control_mode`.
## Attachments, discovery and cleanup
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
use the effective server. `rig ps --nodes --json` includes `tmuxServerArgs` and a
matching `tmuxAttachCommand` for bound tmux seats. `rig tui --shared` uses that
metadata. To attach a terminal directly, use the canonical session name:
```sh
rig terminal attach '<session>'
rig terminal attach --read-only -- '<session>'
```
These commands attach on the daemon's machine. They do not create a seat or
switch somebody else's tmux client. A local CLI pointed at an HTTP remote daemon
cannot attach a same-named local session. For an effectively isolated server,
terminal attachment, shared TUI and desktop windows also compare the daemon's
instance identity with the local durable identity before using local terminal
tools. A loopback URL through a port forward does not bypass this isolation check;
use the matching `OPENRIG_HOME` on the daemon's machine. Inherited servers retain
the existing local-host checks without requiring a matching durable identity.
SSH view tiles follow the viewing instance's effective mode. In `inherit`, they
use remote tmux through `ssh -t`, including `-r` for read-only views, and keep the
bounded, identity-checked reattachment when a seat's session is replaced. The
remote host needs only tmux and a POSIX shell, not OpenRig. Copyable recovery
commands remain plain `ssh -t ... tmux attach`. In `isolated`, tiles run
`rig terminal attach` on the remote host,
using that host's local instance and socket; both the remote CLI and daemon must
support this command. The remote shell must select the right `OPENRIG_HOME` when
using a custom home. Local socket paths are never sent to it, and an isolated
attachment failure does not fall back to a same-named default-server session.
An inherited view does not discover an isolated remote instance's socket; use
the remote instance's `rig terminal attach` directly for that mixed setup.
Discovery and adoption see the selected server. Existing sessions on another
server are not imported automatically. `rig attach --self` reports its current
socket and, for an isolated instance, rejects a different server before binding
or adding a node; an older
CLI without socket metadata must be updated to self-attach to an isolated
instance. A shell outside tmux can still attach as `external_cli`.
`rig destroy --all` reads the recorded effective server before stopping the daemon
or removing its state when isolation or a deferred migration pins it. Ordinary
`inherit` cleanup uses the invoking environment's normal tmux selection, as before;
an old advisory socket is not used to redirect it. Cleanup kills only registered
session names on the selected server.
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
server behavior; requesting `isolated` produces a status note and a pending choice.
## Two instances on one machine
Isolation selects only a tmux server. Give each daemon its own OpenRig home,
database and port as well. For example, from a directory where you want to keep
the two instances:
```sh
mkdir -p .openrig-a .openrig-b
env OPENRIG_HOME="$PWD/.openrig-a" OPENRIG_DB="$PWD/.openrig-a/rig.db" \
OPENRIG_PORT=5311 OPENRIG_TMUX_SERVER=isolated rig daemon start
env OPENRIG_HOME="$PWD/.openrig-b" OPENRIG_DB="$PWD/.openrig-b/rig.db" \
OPENRIG_PORT=5312 OPENRIG_TMUX_SERVER=isolated rig daemon start
```
Use the same instance-specific environment for every subsequent command, including
`rig up`, `rig terminal status --json`, attachment and `rig daemon stop`. Do not
reuse a database between these homes. Other inherited `OPENRIG_*` variables still
apply; start from a shell without selectors for another running instance.
The operating-system home, provider credentials and provider conversation history
remain shared unless configured separately. This setting does not isolate the
filesystem or network. Both inherited and isolated servers support the opt-in
`OPENRIG_TMUX_CONTROL=1` execution path; its control client connects to the same
server as the daemon's other tmux commands. On servers older than tmux 3.7c, or
whose version cannot be recognized as a fixed release, OpenRig uses spawned
clients instead: starting a control client while another session is created can
crash those servers (#2304). This avoids creating OpenRig's own control client;
it cannot protect the server from other tools' control clients.
On a large fleet, the daemon's two seat-activity pollers (the activity sampler and the
structural pane cache) are a large share of its tmux traffic: each runs once a second.
The setting `daemon.seat_poll_interval_ms` slows both down, from the 1000 ms default up to
2000 ms. Set it with `rig config set daemon.seat_poll_interval_ms 2000` (saved in
`config.json`; `rig config` shows it with its source) or with `OPENRIG_SEAT_POLL_INTERVAL_MS`,
which overrides the saved value. It is startup-only: the daemon reads it once when it starts,
so a change takes a daemon restart. `rig config set` refuses a value outside 1000..2000; an
environment value outside it is dropped with a warning and the daemon keeps the saved value or
the default.
A slower cadence makes activity and idle changes show up later, and a Codex permission prompt
can also show up as much as one interval later. Keep the default if a structural sweep of your
tmux server can take more than about 2 s (about 3 s at the default): past that, a sweep that
runs over skips the next tick, the Codex working-frame proof expires, and a working Codex seat
can briefly read idle. At 2000 ms the remaining 1 s margin is also what a lagging event loop
uses up. The two settings compose: `OPENRIG_TMUX_CONTROL=1` makes each tmux command cheaper,
and this one makes there be fewer of them.
+169 -262
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.7.2
# What changed in OpenRig 0.7.1
This note is for an agent on an install that was just upgraded to 0.7.2. It lists only what behaves differently from
0.7.1 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,279 +9,176 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (132, 133,
134, 136, 138 and 139). They add records for runtime switches, stream reader actions, a seat's launch recipe, a Claude
seat's token reference and its tool rules, and rebuild the provider usage readings table to hold labelled Claude
token readings, keeping existing readings. No setting is added or removed.
- **The bundled openrig-core plugin goes from 0.1.18 to 0.1.19.** Only two skills changed: `delegating-work` (its
description now also covers deciding how far to trust a delegated result) and `openrig-user` (reading a queue write,
a seat's own Claude or Codex account, moving one seat to another login, and the MCP handoff and unclaim tools). As in
0.7.1, startup first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not,
and logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Startup also refreshes the person-facing `rigs` skill** in `~/.claude/skills` and `~/.agents/skills`, with the same
copy first, when OpenRig installed it (a copy installed another way is left as it is). It now says what setup and
Codex seats write to a Codex home, offers the operator conversation to a Codex user who declined Codex's remembered
command rule, and covers Rex.
- **Codex seats:** OpenRig's Codex plugin is openrig-core, so Codex sees it as changed. As in 0.7.1, OpenRig renews
its own hook approval only where the installed plugin folder matches what shipped. If a Codex seat stops at Codex's
"Hooks need review" after the upgrade, that folder held other files: review the hooks in Codex.
- **On Linux, check seats that use your session bus** (the keyring, notifications). From their next launch, restore or
handover they get `DBUS_SESSION_BUS_ADDRESS=disabled:` unless their `env:` sets an address (see Known gaps).
- **Launch fixes reach a seat at its next launch, resume, restore or handover,** not a running process: the Read
grants a managed Claude restore needs, a Codex seat's writable git and topology folders, a seat's own account home,
the Linux runtime directory and member tool rules.
- **Close and reopen terminal views opened before the upgrade** (Herdr, cmux or tmux). Only views opened on 0.7.2 keep
their tiles when a seat's session is replaced.
- **If a seat runs on a home given by `rig up --env CLAUDE_CONFIG_DIR=…` or `--env CODEX_HOME=…`,** declare it in the
member's or rig's `env:` instead. 0.7.2 leaves both names out of `--env`, with a note.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
## New things you can do
- **Move a live seat between Codex and Claude Code:** `rig seat set-runtime <seat> --runtime <claude-code|codex>
--reason "…"`, with `--dry-run` first to see the target's settings, what is cleared and the costs. The successor
starts beside the current occupant, reads a handover (the seat's recap, its open rows and the predecessor's screen)
and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "…"`. Only then does the seat move, keeping
its address, connections, rows and claims. Without a confirmation in 180 seconds (`--confirm-timeout`) nothing
changes; `--recover` finishes an interrupted switch. The switch starts a new conversation and isn't written back to
the team file (`docs/reference/getting-started.md`, "Move a seat to the other runtime").
- **Run a seat on its own Claude or Codex account:** sign a home in once natively (`CLAUDE_CONFIG_DIR=<home> claude`
then `/login`, or `CODEX_HOME=<home> codex login`), then declare that absolute path as `CLAUDE_CONFIG_DIR` or
`CODEX_HOME` in the member's or rig's `env:`. Launch, resume, restore, handover, OpenRig's managed Codex config and
hooks, usage limits and status follow it. `rig seat status` and `rig provider status` show the home and where it came
from; a relative value is ignored and shown.
- **Move one seat to another login on the same conversation:** for a Codex seat on Linux, `rig provider precheck --seat
<seat> --to-account <name>`, then `rig provider switch --seat <seat> --to-account <name>`, using a login saved in that
seat's Codex home (`rig auth save`). It saves the outgoing login, activates the chosen one, restarts the seat in place
and prints a restart command for every peer on that home. For a Claude seat, `--to-account <label> --token-file
<absolute path>` moves it to its own subscription token (from `claude setup-token`, in a file only the user can read);
OpenRig keeps the path and label, never the token. `--stored-login` moves it back.
- **Read a labelled Claude token's usage:** `rig provider status --refresh --provider claude --claude-token-file
<absolute path> --credential-label <label>`. Each refresh spends one model turn; status and the TUI show only the
cached reading. The label is yours, not an account Claude reported.
- **Give each Claude member its own tool rules,** even in a shared folder: `claude_permissions: { allow: [...], deny:
[...] }` on the member, or `rig seat set-permissions <seat> --rules <json-file> --reason "…"` for an existing seat.
The update is audited, applies at the next managed launch or resume, and leaves the permission mode alone. Rules join
OpenRig's one `--settings` value and never go into the project's Claude settings. They're native tool rules, not OS
isolation, and an allow list doesn't withdraw broader inherited grants. Limits are under Known gaps.
- **Say what you did about a stream item:** `rig stream act <id>` with exactly one of `--work <qitem>` (work you opened
or joined through the queue), `--decline "<reason>"` or `--duplicate <qitem|stream id>`. Actions are append-only, the
poster's `rig stream show` lists each reader's current action, and retention keeps an item a reader acted on. A
reader who links different work from someone else is told about theirs.
- **Pass held work on over MCP:** `rig_queue_handoff` closes an item as handed off and opens its successor in one step,
and `rig_queue_unclaim` puts a claim back. The MCP server has 36 tools.
- **Release a row parked on a typed blocker:** `rig queue resolve <id> --decision "<evidence>"` now also unparks a row
blocked on `external:`, `fold:` or `auth:`, and records the evidence as a wait resolution, not a person's decision.
- **Open views in Rex:** from a Rex pane, `rig terminal open <view> --window` or the TUI opens the view as a new Rex
session and switches the Rex app to it. `--provider rex` picks it explicitly. Outside Rex the default stays Herdr,
then cmux (`docs/reference/terminal-sessions.md`). Not run on Linux.
- **Hand a community harness its first turn at launch:** a preset's `first_input: true` writes the first startup text
to a private file and passes `OPENRIG_FIRST_INPUT=<path>`; the launcher hands it to the harness. The Cursor example
uses it and now sets `startup_assisted: false`. OpenClaw gains a Gateway-client example (`openclaw-gateway.yaml`)
beside the local one, and both resume a named conversation.
- **Start one seat of a materialized team:** after `rig import <spec> --materialize-only`, `rig launch <rig> <logical
id>` (for example `dev.lead`) starts only that never-started seat. Teams materialized by older daemons aren't covered.
- **Clear a prompt a hook still names:** once `rig capture <seat>` shows it's gone, `rig seat clear-attention <seat>
--reason "<what you saw>"` clears a retained permission or waiting hook, so sends and wakes go through. A refusal that
rests on such a hook now names this step.
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
## Behaviour that changed
- **A working Claude seat that quotes approval text** in its output no longer refuses sends and wakes as if it were at
a prompt, including when its input box is drawn high in a tall pane. The permission dialog, AskUserQuestion and the
MCP menu still refuse.
- **A Claude draft whose first line is blank** reads as a draft, so a send doesn't type into it or submit it.
- **A draft-aware paste to a Claude seat** is no longer read as edited when Claude swaps its footer for "paste again to
expand", so OpenRig no longer withholds Enter for that reason. It isn't confirmed to fix #14 (see Known gaps).
- **A send no longer answers a Pi dialog.** At an adopted Pi TUI's selector (a confirmation), an ordinary `rig send`
used to type and press Enter, choosing the highlighted answer; it now refuses with `target_needs_input` and types
nothing (#2015). #2026 extends this to Pi v1.4.0 extension input and editor dialogs, including help wrapped over up to
three rows in an 80-column pane. The audited deliberate-interaction route is unchanged.
- **Runner-managed Pi seats show and cancel an extension dialog** in the pane, so nothing is approved, and need
attention until their next agent run. On Pi and OMP seats, a second startup block joins the running turn instead of
failing with "Agent is already processing".
- **A send to a busy Codex seat** warns that the message joins the running turn and may redirect it, and names
`--wait-for-idle <seconds>` for separate work.
- **A seat with another pane in its tmux session** gets sends and wakes in its recorded pane instead of a refusal.
- **A wake refused at an approval prompt** retries once when the seat's attention clears, instead of waiting for the
stuck sweep.
- **A seat held at a choice prompt that no hook announces** (a usage-limit dialog, say) now escalates once the refusal
has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 seconds by default). Choice-prompt alerts
follow `queue.wake_human_rung`: under `explicit-only` they stay with the operator seat unless that seat is a person.
Permission-prompt alerts post as before.
- **The parked-owner reminder waits 5 minutes** after a seat starts waiting, so a seat that just handed work off or
asked a person isn't reminded at once; a seat at an approval or question prompt gets no grace. The reminder tells a
seat waiting on a person to hold the row (`rig queue block <id> --on <who> --wake-after <duration>`), not ask again.
- **A dead seat's stuck-sweep finding goes to someone who can act:** the operator agent (`agents.operator_session`)
when set, then the operator seat (`workspace.operator_seat_name`), then the row's creator, skipping any whose session
is confirmed missing. `workspace.operator_seat_name` still decides where approvals go.
- **Rows OpenRig creates itself** (stuck-sweep findings, workflow escalations, continuity batons, diagnoses run with
`--apply`) wake from OpenRig's own sender, with no reply line, instead of from the row's creator. When the sweep
closes its own finding, the actor is `stuck-sweep@system`.
- **A cross-host queue write that times out reads INDETERMINATE,** not failed, and the CLI waits 15 seconds for the
daemon's answer. When `rig queue create` gets no answer, it reads the row back once by its ID and reports an exact
match as created. It never sends the create twice.
- **Codex seats keep write access to their git metadata** after resume, fork and a restore without a pod, so they can
still commit, and to their own topology folder, so they keep `LEARNED.md` and their recap. `rig context recap-write`
falls back to the daemon when the seat's sandbox refuses the write.
- **Recap writes refuse a link** at `RECAP.md` or `recap-superseded/` with `recap_write_refused`, a layout that used to
work. Replace the link with a real file or folder.
- **`rig seat restart --keep-conversation` on a Codex seat** keeps a writable root whose path holds a space, proves the
resume in the `CODEX_HOME` the running Codex uses, and reads complete when a directory it was given has a space. When
the resume isn't proven, it says the pane restarted, not "nothing was restarted".
- **A Codex seat at Codex 0.164.0's sign-in chooser or API-key entry, or 0.162.1's chooser,** reports
`attention_required` with `login_required` instead of a readiness timeout. Nothing is typed into the login screen.
- **Resume recognition:** a resumed Codex whose footer shows a display name with a reasoning label (`GPT-6.1 Sol
medium`) or a non-`gpt` model reads as resumed, so `rig up --existing` doesn't stop. A Claude or Codex resume chooser
drawn high in a tall pane reads as a chooser, not as resumed.
- **Codex hooks:** when a Codex home has a `hooks.json`, OpenRig puts its activity hooks there at the next Codex seat
launch and moves its own old block out of `config.toml`. Your hooks, their order, trust and disables stay; your own
inline hooks stay in `config.toml`. Inline hook trust is keyed by the group's actual position.
- **A managed Claude restore reads its own files without a Read approval:** the seat's marker and restore map, the
generic or configured extra-instruction file, the restore skill files, restore maps under a configured
`preparation_root` and the packet files are granted at launch and resume, at both the named and the real path (so
macOS `/tmp` works).
- **After managed compaction,** a Claude seat's restore starts on the next poll instead of waiting for the screen to
change. `rig compact --state` and seat status show an owed restore stage and its last delivery result; repeated
failures open one queue item for the delegation owner.
- **A Claude startup refused late** (an expired login) no longer gets Enter on its next startup input: OpenRig checks
again before pressing it and reports attention. The text may stay in the input box; don't resend it.
- **A Claude seat with the model alias `haiku` and `plan` mode** gets a line in its launch, restore and `rig seat
status` results: Claude Code answers plan turns with Sonnet; set a full Haiku id with `rig seat set-model` to keep
Haiku. Nothing about the launch changes.
- **The model-divergence monitor** no longer flags a Claude seat pinned to an undated id that answers with its dated
snapshot, or one pinned to a Bedrock cross-Region profile id such as `global.anthropic.<model>`.
- **A pod seat that never launched** starts fresh from its saved launch recipe after a daemon restart, instead of
sitting at a shell. That starts the harness and can use provider usage. Seats materialized before 0.7.2 have no recipe
and report `attention_required`, keeping the shell.
- **`rig down` after a team's tmux server died** treats tmux's "no server running" as absence and records the seats
stopped, so `rig archive` and `rig up` go ahead. A team of plain terminal seats restores as `fully_restored`, and
`rig down` names the terminal sessions it stops.
- **A launch whose tmux made no session** fails with `session_not_created` or `session_unconfirmed` instead of reporting
the seat launched. The message says which directory must be writable.
- **Terminal tiles survive a seat's session replacement:** a tile waits up to 30 seconds for the same session and seat
identity, then reattaches. A fresh replacement's result says how to reopen the view. Input still queued when a
terminal client disconnects is dropped, not typed.
- **Reopening a Herdr view** with `--provider herdr` adds a workspace each time. From inside Herdr, `rig terminal open
<view> --window` focuses a matching one. The `openrig-herdr` skill now says so.
- **A service bundle install without Docker Compose v2** stops with `compose_missing` before writing the target or
creating the team. `rig bundle install --plan` warns instead.
- **`rig export`** copies the declared culture file beside the YAML for a local daemon, and names agent refs that leave
the folder and a rig still named `starter`.
- **`rig daemon start`:** a backlog of queue wakes no longer holds startup past the CLI's readiness wait. When the
database-conflict check can't tell (no `lsof`, a timeout, a listener that doesn't answer), startup warns that
ownership is unknown, under either `daemon.db_conflict` setting.
- **Proof and scope:** proof-source monitoring watches the missions root, not the whole workspace parent. `rig proof
add <absolute path>` finds a slice in another catalogued project. `rig scope mission create` writes
`metadata.name`; an older mission without it needs the line added before it compiles. `rig queue whoami` resolves a
project-tagged row against its catalogued project.
- **Slack:** a registered person's reply to a question parked on someone else reaches the agent as their own message
and leaves the question parked.
- **`rig context get reference/acp-agents.md`** now returns the guide `rig acp run --help` names.
- **TUI and dashboard:** the focused Table and Overview pages load health findings instead of staying `Not assessed`,
the activity stream keeps its connection state across pages, and the dashboard shows "boot not tracked" for a skipped
kernel boot.
- **The kernel operator's guidance** puts a project task on the chosen team's queue rather than doing it itself.
- **`rig auth switch` and `rig provider switch`'s printed undo** say a restored Codex login isn't verified and may need
a fresh sign-in.
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
`queue.unclaimed` and `queue.handed_off` events older than that many days; 14 is a reasonable value
(`rig config set retention.events_days 14`). Every other type is kept at any age, because restore, startup proof and
the seat readers fold from them, and the newest event is always kept. What you give up: pruned events no longer
replay from `/api/events` (an older cursor or a large `?replay`), no longer appear in `rig telemetry events` (which
reports the gap) or in older log and feed entries, and a later change of the setting can't bring them back. A new
value is read at the next sweep: the daily one, or the next daemon start. The first sweep after turning it on works
through the old history in short steps while the daemon keeps answering. `retention.enabled false` stops every
retention pass from the next daemon start.
- **The database file does not shrink by itself.** Pruned rows free pages that new events reuse, which slows the file's
growth; other tables still grow. To give the space back to the disk: find the file with `rig config get db.path`, stop the daemon
(`rig daemon stop`), copy the file aside, run `sqlite3 <that file> "VACUUM;"` (it needs free disk about the size of
the file and can take a few minutes), then `rig daemon start`. Never vacuum the file of a running daemon.
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
## Output that changed shape
- **`rig capture` and the MCP capture tool** add `promptPane: { width, height, hint }` when a seat at a prompt has a
pane smaller than 80x24, and the CLI prints the resize hint on stderr.
- **`rig seat status`** shows the seat's runtime home and where it came from, any ignored home, a Claude token
reference and member tool rules. `--json` always has `permissions.account.advisories`, empty unless a note applies.
- **`rig stream show`** lists each reader's current action, flags `parallelWork`, and reads `readers: "unavailable"`
from an older daemon. The expired-cursor 410 says how `--since` resumes.
- **Slack posts start with their sender** (`From <seat>`, with ` on <machine>` when known), and a wake-rung escalation
reads `Waiting on <seat>: <summary>`. Rows from Slack read `<address> via Slack: …`.
- **A wake from a row OpenRig created** ends with "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
- **Stuck-sweep findings** name the rule that chose their recovery seat, and a stalled-after-claim age counts from the
stall, not from the claim.
- **The parked diagnosis** reports `activity.since`.
- **Cross-host queue writes:** a timed-out forward's 502 carries `outcome: "indeterminate"`, the CLI's headline reads
"queue request got no answer in time (routing to host '…')", and a read-back reports "The create committed after all:
row … (read back by its ID)".
- **`rig queue update --help` and the `closure_fields_not_admitted` refusal** say how to cancel a row.
- **`rig ps --nodes --json`** carries `login_required` for a Codex seat at sign-in; a restore result marks a plain
terminal seat `noConversation`.
- **A send to a seat mid-switch** is held at once: `outcome: retained`, `reason: runtime_switch_in_progress`.
- **New codes:** `permission_rules_unavailable` (a fork whose source rules can't be read), `occupant_retired_by_switch`
and `switch_not_committed` (the two processes of a runtime switch), `runtime_unchanged` and the `runtime_switch_*`
refusals, `session_not_created`, `session_unconfirmed`, `compose_missing`, `rex_outcome_unknown`,
`invalid_claude_token_selection`, and input checks on `rig stream act`.
- **Help:** 14 subcommands that had no summary have one, and `rig skill audit` ends by saying it doesn't judge whether
a refresh is safe, naming the read-only `rig skill loadout` check.
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
## What to stop doing
- **Don't pass `CLAUDE_CONFIG_DIR` or `CODEX_HOME` with `rig up --env`.** Declare the home in `env:`.
- **Don't rebuild a team to move one seat** to the other runtime or another login. Use `rig seat set-runtime` or
`rig provider switch --seat`.
- **Don't close and re-create to pass work on over MCP.** Use `rig_queue_handoff`.
- **Don't cancel with `--state canceled --closure-reason canceled`.** Use `--state done --closure-reason canceled`.
- **Don't take the first `qitem-…` from `rig queue create`'s merged output.** The request ID on stderr isn't a row:
read `qitemId` from stdout and check the exit code.
- **Don't retry a cross-host queue write that timed out.** Reconcile by its ID first.
- **Don't reopen a Herdr view with `--provider herdr` to get back to it.** Focus it, or use `--window` from inside Herdr.
- **Don't put one member's tool rules in a shared project's Claude settings.** Use `claude_permissions`.
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
## Known gaps in 0.7.2
## Known gaps in 0.7.1
- **On Linux, a seat now gets the daemon's runtime directory with a session bus address that can't connect** (#2036).
When the daemon has an absolute `XDG_RUNTIME_DIR`, a seat launched, restored or handed over gets it with
`DBUS_SESSION_BUS_ADDRESS=disabled:`, so its D-Bus clients don't find your session bus on their own. This is a
default, not isolation: `systemctl --user` reaches your user service manager from a seat. A shell startup file that
sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`; to use a session bus, set the address in the rig's
or member's `env:`. A Codex seat's stdio MCP server still needs `env_vars = ["XDG_RUNTIME_DIR"]` in Codex's
`config.toml` to see the variable. macOS, a daemon started without `XDG_RUNTIME_DIR` and
`terminal.inherit_tmux_environment` on are unchanged.
- **Per-member Claude tool rules** (#1914): member denies aren't claimed to hold in bypass mode (`full_bypass`).
Handover and restore carry the rules, but aren't proven to keep the same conversation. Checked natively: two seats in
one folder each enforce their own rules, a managed policy wins, and fresh, resume and fork launches enforce them.
- **Going back to 0.7.1:** like any version before #1914, it drops the stored member rules from launch, so set
equivalent native rules first (`docs/reference/rig-spec.md`, "Downgrade"). It also refuses a community preset that
sets `first_input`. No other way back to 0.7.1 has been tested.
- **Pi dialog recognition is a bounded screen check.** #2015 recognizes Pi's selector by its own chrome; with more than
six non-blank rows after its border, or under a widget that draws its own border, a send goes through as before.
#2026 adds Pi v1.4.0's extension input and editor dialogs; clipped or incomplete help, help wrapped over more than
three rows and custom extension screens keep their earlier reading, and a draft that copies a dialog's whole frame
reads as that dialog. Each was checked natively on its own cases (#2015 its confirmation dialog, #2026 the input and
editor dialogs on its own build), not on every layout. Check the pane before sending to an adopted Pi seat that may be
at a dialog. Neither
check was repeated on the final 0.7.2 build.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build. 0.7.2 treats the "paste again to expand" hint as part of
the composer, but the original failure didn't reproduce in its native check, so this isn't confirmed fixed; #14
remains open.
- **An idle Claude seat whose recent output quotes prompt text** can still read as at a prompt and refuse sends, as can
a seat with a draft, a mode bar OpenRig doesn't recognise or an unframed input box. Check the pane.
- **A Claude startup refusal that arrives after OpenRig's last check can still race Enter.** If a seat with an expired
login shows staged text in its input box, sign in, then clear or send that text yourself.
- **A managed Claude restore can still ask for a Read approval** when `policies.claude_compaction.message_file_path` is
relative, or for a packet file after a resume changed the conversation id. The session transcript isn't granted. A native
check on Claude 2.1.309 passed the Read grants with no approval prompt; a complete restore with no help isn't claimed.
- **`rig seat set-runtime`:** Claude launch additions such as an agent file aren't applied to a switched seat, startup
slash commands aren't run, and a person typing into the old pane of a draft-aware seat stops the switch (the current
occupant keeps the seat). A message sent during a switch is held and must be sent again.
- **A seat's own account:** a conversation saved under a seat's earlier home doesn't resume in its new one, and live
usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. `rig provider switch --seat`
moves a Codex seat on Linux only. Other seats on that Codex home keep the old login until their next token refresh,
then stop: run the restart command the switch printed for each.
- **Seat account and runtime moves weren't run with real accounts before this release** (#1895, #1937, #1972, #1980).
The tests use stubs; check a moved seat's account and conversation yourself after the first move.
- **A Codex seat whose queue-state folder or team workspace path holds a space** can still read as not resumed after an
ordinary resume or restore. An in-place restart is fixed.
- **Choice-prompt alerts go to a person one at a time per machine.** While the first one's seat stays blocked, later
choice alerts wait unposted; they're visible as rows.
- **Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting** (`needs-input`). Use
OpenRig's view or the Feed to find waiting seats.
- **Proof-source watching still recurses through a large missions tree,** and the reported Linux daemon stalls (#1813)
weren't reproduced to confirm that the narrower watch helps.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no automatic usage reading;** the reader
reports `not_subscription_login`. Read one on demand with `rig provider status --refresh --provider claude
--claude-token-file <path> --credential-label <label>`, which spends a model turn.
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
@@ -294,11 +191,21 @@ after an upgrade it can still hold the previous version's note.
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`, which adds a new workspace; close the old one.
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
+8 -33
View File
@@ -1,38 +1,15 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
2. Write the release's GitHub Release notes. No new file is added to this directory.
3. Run the **substance gate** at the exact release cut. The gate derives every
file under the shipped pack sources, verifies a hash-bound human judgment
and an individual disposition for each mechanical candidate, scans the
@@ -79,16 +56,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create a GitHub Release using the same file:
5. Create the GitHub Release with the notes from step 2:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
```
6. Publish the npm package if that is part of the release flow.
6. Publish the npm package.
## Guidance
@@ -101,5 +78,3 @@ For each release:
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+1 -1
View File
@@ -260,7 +260,7 @@ Operators can install `gstack` (45 skills) or `obra-superpowers`
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
- Founder-walk VM at `http://192.0.2.10:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
+324 -91
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.2",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.2",
"version": "0.7.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -473,11 +473,13 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -489,11 +491,13 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -505,11 +509,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -521,11 +527,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -537,11 +545,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -553,11 +563,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -569,11 +581,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -585,11 +599,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -601,11 +617,13 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -617,11 +635,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -633,11 +653,13 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -649,11 +671,13 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -665,11 +689,13 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -681,11 +707,13 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -697,11 +725,13 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -713,11 +743,13 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -729,11 +761,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -745,11 +779,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -761,11 +797,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -777,11 +815,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -793,11 +833,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -809,11 +851,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -825,11 +869,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -841,11 +887,13 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -857,11 +905,13 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -873,11 +923,13 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1033,13 +1085,60 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz",
"integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.8",
"jose": "^6.1.3",
"pkce-challenge": "^5.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
"integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/node": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.2.tgz",
"integrity": "sha512-IIcIqBpr6NCQQs5DgJTgvbSOAw+3iaR/uquqg+e0nXCdudfjPg/80x0TseUnQx14AUAtE4fsJCEpJKPyb6WPVA==",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.1.3",
"hono": "^4.11.4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@modelcontextprotocol/server": "^2.4.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1073,6 +1172,19 @@
}
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
"integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3008,20 +3120,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -3031,10 +3143,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -3269,9 +3394,9 @@
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3674,9 +3799,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3774,9 +3899,9 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -3793,15 +3918,15 @@
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"body-parser": "^2.3.0",
"content-disposition": "^2.0.1",
"content-type": "^2.0.0",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
@@ -3817,14 +3942,14 @@
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"proxy-addr": "^2.0.8",
"qs": "^6.16.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"type-is": "^2.1.0",
"vary": "^1.1.2"
},
"engines": {
@@ -3836,12 +3961,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -3853,6 +3979,19 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4121,6 +4260,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -4254,9 +4400,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.7.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4546,12 +4692,16 @@
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
@@ -4692,12 +4842,32 @@
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/node-addon-api": {
@@ -4762,15 +4932,19 @@
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/once": {
@@ -4820,9 +4994,9 @@
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -5063,10 +5237,22 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -5074,6 +5260,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/punycode": {
@@ -5087,12 +5277,13 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -5122,12 +5313,16 @@
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
@@ -5146,9 +5341,9 @@
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5355,6 +5550,16 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5585,14 +5790,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -5604,13 +5809,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -5663,6 +5868,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@@ -6058,17 +6270,34 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/typescript": {
@@ -7031,12 +7260,12 @@
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
"zod": "^3.25.28 || ^4"
}
},
"node_modules/zustand": {
@@ -7069,13 +7298,15 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.2",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/sdk": "^1.28.0",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -7094,6 +7325,7 @@
"rig": "dist/bin-wrapper.js"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -7104,7 +7336,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.2",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7121,6 +7353,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@types/tar": "^6.1.13",
"proper-lockfile": "^4.1.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -7146,7 +7379,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.2",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.2",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -19,6 +19,7 @@
"typecheck:prep": "npm run build -w packages/daemon",
"lint": "npm run typecheck:prep && tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
"build:package": "bash scripts/build-package.sh",
"pack:release": "node scripts/pack-release.mjs",
"test:tui-package": "node scripts/test-tui-package.mjs",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check",
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+36
View File
@@ -0,0 +1,36 @@
# @openrig/acp
OpenRig as an agent in apps that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as
Zed. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work
and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply.
This package has one command, `openrig-acp`. It starts `rig acp serve` from the `@openrig/cli` version it depends on,
and warns on stderr if it finds another, so ACP apps and the ACP registry can launch it with `npx`.
## What you need
- OpenRig on this computer, with its daemon running (`rig daemon start`) and a team.
- The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as
`--to <member@rig>`.
- You, as a person OpenRig knows: register with
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` (the only registered person
is the default; with several, choose one with `rig gateway default <id>`), or pass `--as <address>`. The team's
questions reach an `<id>@external` address only once it's registered.
The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are.
This agent talks to the same OpenRig daemon as `rig` on this computer: the local one, unless `OPENRIG_URL` names another.
## In an app, without the registry
Add a custom agent whose command is `npx` with the arguments `@openrig/acp@<version>`, plus `--to <member@rig>` if
you haven't set `acp.to`. In Zed's `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "npx", "args": ["@openrig/acp@<version>", "--to", "lead@my-team"] }
}
```
If `rig` is already installed, `rig acp serve` is the same agent without this package.
Guide: `rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a
// package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the @openrig/cli this package
// depends on, as Node resolves it (never a `rig` on the PATH), with the app's stdin and stdout, arguments, environment
// and working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to
// stderr.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
let rig;
try {
const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
// Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. That copy
// most likely still works, and one that doesn't fails visibly, so say which version runs and run it.
const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"];
if (manifest.version !== wanted) {
console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, not the ${wanted} this package pins; running it anyway. Reinstall @openrig/acp to get ${wanted}.`);
}
rig = join(dirname(manifestPath), manifest.bin.rig);
} catch (error) {
console.error(`openrig-acp: @openrig/cli isn't installed beside this package (${error.message}). Reinstall @openrig/acp.`);
process.exit(1);
}
const child = spawn(process.execPath, [rig, "acp", "serve", ...process.argv.slice(2)], { stdio: "inherit" });
const forward = (signal) => child.kill(signal);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, forward);
child.on("error", (error) => {
console.error(`openrig-acp: couldn't start rig acp serve: ${error.message}`);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
// End the same way the server did, so the app sees the signal rather than an exit code.
process.removeListener(signal, forward);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@openrig/acp",
"version": "0.7.1",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
"openrig-acp": "bin/openrig-acp.js"
},
"files": [
"bin",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.1"
},
"license": "Apache-2.0",
"author": {
"name": "mvschwarz",
"url": "https://esoteric.run"
},
"homepage": "https://openrig.dev",
"repository": {
"type": "git",
"url": "https://github.com/mvschwarz/openrig",
"directory": "packages/acp"
},
"keywords": [
"ai",
"agents",
"acp",
"agent-client-protocol",
"openrig"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.1",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
"authors": [
"mvschwarz"
],
"license": "Apache-2.0",
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]"
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="8 -754 596 808" fill="currentColor"><g transform="scale(1,-1)"><path d="M306.0 -14.0Q426.0 -14.0 495.0 59.5Q564.0 133.0 564.0 272.0V428.0Q564.0 563.0 495.0 638.5Q426.0 714.0 306.0 714.0Q186.0 714.0 117.0 638.5Q83.32444763183594 601.652099609375 66.08428192138672 550.631591796875L195.21009826660156 523.18505859375Q207.8548583984375 554.14111328125 233.0 573.0Q261.0 594.0 306.0 594.0Q351.0 594.0 378.5 573.0Q406.0 552.0 419.0 516.0Q432.0 480.0 432.0 436.0V264.0Q432.0 194.0 405.0 150.0Q378.0 106.0 306.0 106.0Q234.0 106.0 207.0 150.0Q180.0 194.0 180.0 264.0V436.0Q180.0 461.6716613769531 184.25511169433594 484.6199951171875L55.7750358581543 511.9292907714844Q48.0 473.3169250488281 48.0 428.0V272.0Q48.0 133.0 117.0 59.5Q186.0 -14.0 306.0 -14.0ZM138.0 176.0C138.0 162.7451629638672 127.25483703613281 152.0 114.0 152.0C100.74516296386719 152.0 90.0 162.7451629638672 90.0 176.0C90.0 189.2548370361328 100.74516296386719 200.0 114.0 200.0C127.25483703613281 200.0 138.0 189.2548370361328 138.0 176.0Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.2",
"version": "0.7.1",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -47,7 +47,9 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/sdk": "^1.28.0",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -62,6 +64,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+540
View File
@@ -0,0 +1,540 @@
import { randomBytes } from "node:crypto";
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
// this process waiting, never the team.
export const ACP_PROTOCOL_VERSION = 1;
export const AUTH_METHOD_ID = "openrig-local";
export const SESSION_TAG = "acp-session:";
export const CWD_TAG = "acp-cwd:";
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
const FOOTER = "\n\n---\nSent by ";
/** One queue row as this adapter reads it. */
export interface TeamRow {
qitemId: string;
state: string;
destinationSession: string;
handedOffTo: string | null;
closureReason: string | null;
closureTarget: string | null;
resolution: string | null;
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
closer: string | null;
}
export interface TeamTransition {
transitionId: number;
ts: string;
state: string;
transitionNote: string | null;
actorSession: string;
}
/** A row to the caller that asks for a decision, with the choices it offers (none: words only). */
export interface TeamAsk {
qitemId: string;
sourceSession: string;
state: string;
tsCreated: string;
summary: string | null;
body: string;
tags: string[];
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
/** What's recorded so far, by question id: the chosen option's id, or the words of a typed reply. */
answers: Record<string, string>;
}
/** A row the caller sent through rig acp serve. */
export interface SentRow {
qitemId: string;
tsCreated: string;
tsUpdated: string;
summary: string | null;
body: string;
tags: string[];
}
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
export class TeamRefused extends Error {}
/** The queue verbs the adapter uses, acting as the caller. */
export interface TeamQueue {
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
* TeamRefused only for a refusal the daemon answered with. */
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
show(qitemId: string): Promise<TeamRow>;
/** The row, or null when this machine has no such row; throws when it can't tell. */
find(qitemId: string): Promise<TeamRow | null>;
transitions(qitemId: string): Promise<TeamTransition[]>;
/** The row a handoff of `qitemId` created on this machine, if any. */
successor(qitemId: string): Promise<string | undefined>;
/** Open asks addressed to `person`. */
openAsks(person: string): Promise<TeamAsk[]>;
/** Every ask to `person` tagged for one chat, open or settled, to replay with it. */
sessionAsks(person: string, sessionId: string): Promise<TeamAsk[]>;
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
/** Rows the caller sent through rig acp serve. */
sent(caller: string): Promise<SentRow[]>;
}
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
export interface TeamConnection {
queue: TeamQueue;
caller: string;
receiver: string | undefined;
/** Something about this connection the person should know, said once in each session before its first prompt's work. */
notice?: string;
}
export interface AcpAgentOptions {
/** Reaches the daemon (`rig`'s: the local one, or OPENRIG_URL's) and resolves the caller; returns guidance when it can't.
* Called lazily, never at initialize. */
connect: () => Promise<TeamConnection | { error: string }>;
version: string;
pollMs: number;
log?: (line: string) => void;
sleep?: (ms: number) => Promise<void>;
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
closed?: Promise<void>;
}
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
const QUESTION_SETTLE_MS = 2000;
const AUTH_REQUIRED = -32000;
const INVALID_PARAMS = -32602;
class AcpError extends Error {
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
}
interface Session {
id: string;
cwd: string;
/** The first row each prompt created, oldest first, with the last row its work reached. */
sent: Array<{ root: string; tail: string }>;
turn?: { cancel: () => void };
noticed?: boolean;
}
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
interface TurnQuestions {
finished: boolean;
/** The person cancelled the turn: no further question opens, though one the app is showing still settles. */
cancelled: boolean;
/** Questions the app is still showing, by tool call id, each with how the chat names it. */
outstanding: Map<string, string>;
tasks: Set<Promise<void>>;
}
type Update = Record<string, unknown>;
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
export function promptText(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text" && block.text) parts.push(block.text);
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
else if (block.type === "resource" && block.resource?.uri) {
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
}
}
return parts.join("\n\n").trim();
}
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
function oneLine(text: string, max: number): string {
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/** The person's own words in a prompt, for the row's summary and the session's title. An app that wraps them in its
* own instructions marks them (T3 Code sends several blocks of harness text, then `<user_request>…</user_request>`);
* the row's body keeps everything the app sent. Only a prompt with one opening and one closing mark is read this
* way: with more, an example could be taken for the request, so the prompt stands as sent. */
function personsWords(text: string): string {
if (text.split("<user_request>").length !== 2 || text.split("</user_request>").length !== 2) return text;
const marked = /<user_request>([\s\S]*?)<\/user_request>/.exec(text)?.[1];
return marked?.trim() ? marked : text;
}
const toolStatus = (state: string) =>
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
const transitionLine = (t: TeamTransition) =>
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
/** Where one prompt's work stands after a read of its rows. */
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
class ChainFollower {
private readonly chain: string[];
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string; title: string }>();
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
readonly seats = new Set<string>();
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
this.chain = [root];
}
async read(): Promise<ChainState> {
for (;;) {
let tail!: TeamRow;
for (const qitemId of this.chain) {
const row = await this.queue.show(qitemId);
const transitions = await this.queue.transitions(qitemId);
tail = row;
this.seats.add(row.destinationSession);
const status = toolStatus(row.state);
let entry = this.seen.get(qitemId);
if (!entry) {
const title = qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`;
entry = { lines: [], lastId: 0, status, title };
this.seen.set(qitemId, entry);
this.emit({ sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, title, rawInput: { qitemId, destination: row.destinationSession } });
}
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
if (fresh.length > 0 || status !== entry.status) {
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
entry.status = status;
// An app that shows a step collapsed still shows its title, so the latest note leads it: a narrow panel cuts the end.
const noted = fresh.filter((t) => t.transitionNote).at(-1);
this.emit({
sessionUpdate: "tool_call_update", toolCallId: qitemId, status,
...(noted ? { title: `${noted.actorSession}: ${oneLine(noted.transitionNote!, 100)} · ${entry.title}` } : {}),
...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}),
});
}
}
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
const next = await this.queue.successor(tail.qitemId);
if (next) { this.chain.push(next); continue; }
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
}
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
}
}
}
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
const sessions = new Map<string, Session>();
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const log = options.log ?? (() => {});
let clientName = "an ACP app";
/** The app asks forms (ACP `elicitation/create`, mode form), so a question's choices go to it as a form, each by its
* label. A permission request shows them only in apps that list each option: T3 Code shows a fixed Approve, which
* sends the first. */
let asksForms = false;
let connection: TeamConnection | undefined;
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
const connected = async (): Promise<TeamConnection> => {
if (connection) return connection;
const result = await options.connect();
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
connection = result;
return result;
};
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
/** A question the app showed ends unanswered: its card says so, and so does the chat, which every app shows, since an
* app need not show a card's later text. */
const unanswered = (sessionId: string, toolCallId: string, named: string, why: string) => {
update(sessionId, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
say(sessionId, `\n\n${why} ${named} is still open in the OpenRig TUI and Slack.\n\n`);
};
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
/** Put one ask's open questions to the person, as a form where the app asks forms and otherwise as permission
* requests, one at a time, and record each choice. A reply that arrives after the turn ended answers nothing here:
* the question stays open in the TUI and Slack. */
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
if (ask.questions.length === 0) {
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
return;
}
for (const q of ask.questions.filter((question) => !(question.id in ask.answers))) {
if (turn.finished || turn.cancelled) return;
const toolCallId = `${ask.qitemId}#${q.id}`;
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
const label = (o: (typeof q.options)[number]) => (o.recommended ? `${o.label} (recommended)` : o.label);
const named = `${ask.sourceSession}'s question "${q.question}" (${ask.qitemId})`;
let chosenId: string | undefined;
turn.outstanding.set(toolCallId, named);
try {
if (asksForms) {
// Each choice is offered as its label, the one form every app shows as choices: T3 Code 0.1.7 builds them only
// from `enum`, a value serving as both label and answer, and reads `oneOf` as free text. Labels that repeat
// carry their option's id, so a value names one choice; the answer is mapped back by that exact value.
const shown = q.options.map(label);
const values = new Set(shown).size === shown.length ? shown : q.options.map((o, i) => `${shown[i]} (${o.id})`);
const result = (await peer.request("elicitation/create", {
sessionId, toolCallId, mode: "form", message: `${ask.sourceSession} asks: ${q.question}`,
requestedSchema: { type: "object", required: [q.id], properties: { [q.id]: { type: "string", title: q.question, enum: values } } },
})) as { action?: string; content?: Record<string, unknown> | null } | undefined;
const value = result?.action === "accept" ? result.content?.[q.id] : undefined;
chosenId = typeof value === "string" ? q.options[values.indexOf(value)]?.id : undefined;
} else {
const result = (await peer.request("session/request_permission", {
sessionId,
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
options: q.options.map((o) => ({ optionId: o.id, name: label(o), kind: "allow_once" })),
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
chosenId = result?.outcome?.outcome === "selected" ? result.outcome.optionId : undefined;
}
} catch (error) {
if (!turn.finished) unanswered(sessionId, toolCallId, named, `This app couldn't show the question (${(error as Error).message}).`);
return;
} finally {
turn.outstanding.delete(toolCallId);
}
if (turn.finished) return;
// Only one of the question's own options is recorded: the app's reply is checked again here.
const chosen = q.options.find((o) => o.id === chosenId);
if (!chosen) {
unanswered(sessionId, toolCallId, named, "Not answered here.");
return;
}
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
emit(recorded.ok
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
}
};
peer.onRequest("initialize", async (params) => {
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string }; clientCapabilities?: { elicitation?: { form?: unknown } | null } };
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
// ACP counts a mode as supported only when its field is present and not null.
asksForms = p.clientCapabilities?.elicitation?.form != null;
return {
protocolVersion: ACP_PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: false, audio: false, embeddedContext: true },
mcpCapabilities: { http: false, sse: false },
sessionCapabilities: { list: {} },
},
authMethods: [{
id: AUTH_METHOD_ID,
name: "OpenRig on this machine",
description: "Sends your work to your OpenRig team, as you, through the same OpenRig daemon as `rig` on this computer: the local one (start it with `rig daemon start`), unless OPENRIG_URL names another.",
}],
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
};
});
peer.onRequest("authenticate", async (params) => {
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
const team = await connected();
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
return {};
});
peer.onRequest("session/new", async (params) => {
await connected();
const id = newSessionId();
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
return { sessionId: id };
});
peer.onRequest("session/list", async (params) => {
const team = await connected();
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
if (!sessionId) continue;
const known = byId.get(sessionId);
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
byId.set(sessionId, {
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
title: oneLine(personsWords(row.body.split(FOOTER)[0] ?? ""), 100), updatedAt: row.tsUpdated,
});
}
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return { sessions: listed };
});
peer.onRequest("session/load", async (params) => {
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
const team = await connected();
const sessionId = String(p.sessionId ?? "");
const roots = await sessionRoots(team, sessionId);
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
for (const [i, row] of roots.entries()) {
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
const state = await follower.read();
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
const next = roots[i + 1]?.tsCreated;
for (const ask of asks.filter((a) => (i === 0 || a.tsCreated >= row.tsCreated) && (next === undefined || a.tsCreated < next))) {
for (const q of ask.questions) {
const answer = ask.answers[q.id];
const outcome = answer !== undefined ? `You answered: ${q.options.find((o) => o.id === answer)?.label ?? answer}.`
: ACTIVE_STATES.has(ask.state) ? "Not answered yet. It's still open in the OpenRig TUI and Slack." : `Not answered: ${ask.qitemId} was closed (${ask.state}).`;
update(sessionId, {
sessionUpdate: "tool_call", toolCallId: `${ask.qitemId}#${q.id}`, kind: "other", status: answer === undefined && ACTIVE_STATES.has(ask.state) ? "pending" : "completed",
title: `${ask.sourceSession} asks: ${q.question}`, content: [...textContent(ask.body), ...textContent(outcome)],
});
}
}
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
}
sessions.set(sessionId, session);
return {};
});
peer.onNotification("session/cancel", (params) => {
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
});
peer.onRequest("session/prompt", async (params) => {
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
const team = await connected();
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
if (!text) {
say(session.id, "Nothing to send: the prompt had no text.");
return { stopReason: "end_turn" };
}
if (!team.receiver) {
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Choose the seat that takes your team's new work with `rig config set acp.to <member@rig>`, or add `--to <member@rig>` to the agent's command in your app's settings.");
return { stopReason: "end_turn" };
}
if (team.notice && !session.noticed) {
session.noticed = true;
say(session.id, `${team.notice}\n\n`);
}
let wake: () => void = () => {};
const questions: TurnQuestions = { finished: false, cancelled: false, outstanding: new Map(), tasks: new Set() };
session.turn = { cancel: () => { questions.cancelled = true; wake(); } };
let root: string | undefined;
try {
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
const earlier = await Promise.all(session.sent.map(async (s) => {
const row = await team.queue.find(s.tail).catch(() => undefined);
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
}));
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
const turnStarted = new Date().toISOString();
const qitemId = newQitemId();
try {
await team.queue.create({
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(personsWords(text), 100)}`,
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
});
} catch (error) {
const reason = (error as Error).message;
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
// No answer: the request may still land after any readback, so only a row that reads back settles it.
const landed = await team.queue.find(qitemId).catch(() => undefined);
if (!landed) {
session.sent.push({ root: qitemId, tail: qitemId });
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
}
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
}
root = qitemId;
const sent = { root, tail: root };
session.sent.push(sent);
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
// ponytail: polls the daemon for the chain's rows, their transitions and the caller's open asks; the
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
const seen = new Set<string>();
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
const finishQuestions = (why: string) => {
for (const [toolCallId, named] of questions.outstanding) unanswered(session.id, toolCallId, named, why);
questions.finished = true;
};
for (;;) {
const state = await follower.read();
sent.tail = state.tail.qitemId;
if (state.kind === "ended") {
finishQuestions("The work closed before this was answered here.");
say(session.id, state.reply);
return { stopReason: "end_turn" };
}
const asks = await team.queue.openAsks(team.caller);
// After a cancel, even one that landed during that read, no new question goes to the person.
for (const ask of questions.cancelled ? [] : asks) {
if (seen.has(ask.qitemId)) continue;
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
// another chat's, so it isn't asked here; it's named, with where to answer it.
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
seen.add(ask.qitemId);
const task: Promise<void> = askPerson(team, session.id, ask, questions)
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
.finally(() => questions.tasks.delete(task));
questions.tasks.add(task);
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
seen.add(ask.qitemId);
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
}
}
if (questions.cancelled) {
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
finishQuestions("Not answered here: the turn was cancelled.");
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
return { stopReason: "cancelled" };
}
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
}
} catch (error) {
if (error instanceof AcpError || !root) throw error;
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
} finally {
questions.finished = true;
session.turn = undefined;
}
});
}
+58 -10
View File
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer, type JsonRpcErrorObject } from "./json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
@@ -48,10 +48,22 @@ export interface AcpRunOptions {
detachRequested?: Promise<void>;
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
exitWaitMs?: number;
/** A harness's own evidence that a `session/load` error means that session doesn't exist. Without it, or when it
* doesn't match, the load error reads `failed` with the agent's code and message: no code means missing by itself. */
missingSession?: (error: JsonRpcErrorObject, sessionId: string) => boolean;
/** Called once with what the stop found: the agent's exit and whether any process carrying the run's marker remains. */
onSettled?: (report: string) => void;
}
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown" | "missing";
/** What the agent itself reported; OpenRig measures none of it. */
export interface AcpUsage {
/** The prompt response's `usage` (an unstable ACP field), as the agent counted it. */
tokens?: { total?: number; input?: number; output?: number; thought?: number; cachedRead?: number; cachedWrite?: number };
/** The last `usage_update` cost: the agent's own cumulative figure for the whole session, in its unit. */
cost?: { amount: number; currency: string };
}
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string; usage?: AcpUsage }
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
@@ -87,6 +99,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
let sessionId: string | undefined;
let text = "";
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
const reported: AcpUsage = {};
let toolCalls = 0;
let lastTool = "";
let lastProgressNote = Date.now();
@@ -104,6 +117,8 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
progress(`\n[tool] ${lastTool}\n`);
} else if (update.sessionUpdate === "usage_update") {
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
const cost = update.cost as { amount?: unknown; currency?: unknown } | null | undefined;
if (typeof cost?.amount === "number" && typeof cost.currency === "string") reported.cost = { amount: cost.amount, currency: cost.currency };
}
if (Date.now() - lastProgressNote >= 60_000) {
lastProgressNote = Date.now();
@@ -153,10 +168,11 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
const withUsage: AcpOutcome = { ...outcome, usage: { ...reported } };
await queue.update(row.qitemId, {
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.\n${describeUsage(withUsage.usage)}`,
});
return outcome;
return withUsage;
};
try {
@@ -166,7 +182,12 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const loads = init.agentCapabilities?.loadSession === true;
if (options.loadSession) {
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
try {
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
} catch (error) {
if (!(error instanceof JsonRpcRequestError) || !options.missingSession?.(error.error, options.loadSession)) throw error;
return await record({ kind: "missing", text: "", summary: `session ${options.loadSession} doesn't exist for this agent (session/load returned ${describeError(error.error)}); no new session was started` });
}
sessionId = options.loadSession;
} else {
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
@@ -179,20 +200,29 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
if (turnOver || cancelRequestedAt) return;
cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId });
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
try {
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
} catch (error) {
console.error(`[rig acp] could not record the cancel request: ${String(error)}`);
}
});
// A detach settles the turn here even when the agent keeps its stdout open after stdin closes.
const detached = new Promise<never>((_, reject) => {
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
});
detached.catch(() => {});
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string; usage?: Record<string, unknown> };
turnOver = true;
const tokens = response.usage;
if (tokens && typeof tokens === "object") {
const count = (key: string) => (typeof tokens[key] === "number" ? tokens[key] as number : undefined);
reported.tokens = { total: count("totalTokens"), input: count("inputTokens"), output: count("outputTokens"), thought: count("thoughtTokens"), cachedRead: count("cachedReadTokens"), cachedWrite: count("cachedWriteTokens") };
}
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
} catch (error) {
turnOver = true;
if (error instanceof JsonRpcRequestError) {
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${describeError(error.error)}` });
}
if (error instanceof DetachedBeforeTurnEnd) {
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
@@ -208,7 +238,9 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const settled = left === undefined ? "unknown (the process table couldn't be read)"
: left.length === 0 ? "settled: no process carrying this run's marker remains"
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
const report = `agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}`;
options.onSettled?.(report);
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: ${report}` }).catch(() => {});
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
}
}
@@ -227,6 +259,22 @@ export function classify(stopReason: string | undefined, text: string, usage: {
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
}
const describeError = (error: JsonRpcErrorObject) => `${error.code} ${error.message}${error.data === undefined ? "" : ` ${JSON.stringify(error.data)}`}`;
/** Usage and cost as the agent reported them, with where each came from; a figure it didn't report reads unknown. */
export function describeUsage(usage: AcpUsage | undefined): string {
const t = usage?.tokens;
const parts = t ? [["in", t.input], ["out", t.output], ["thought", t.thought], ["cache read", t.cachedRead], ["cache write", t.cachedWrite]]
.filter(([, n]) => n !== undefined).map(([label, n]) => `${n} ${label}`) : [];
const tokens = t?.total !== undefined || parts.length > 0
? `${t?.total !== undefined ? `${t.total} tokens` : "tokens"}${parts.length ? ` (${parts.join(", ")})` : ""}, from the agent's prompt response`
: "tokens unknown (the agent's prompt response reported none)";
const cost = usage?.cost
? `cost ${usage.cost.amount} ${usage.cost.currency}, the agent's own cumulative figure for the session (usage_update)`
: "cost unknown (the agent reported none)";
return `Usage as the agent reported it: ${tokens}; ${cost}.`;
}
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
const value = answer?.trim();
+60 -34
View File
@@ -1,9 +1,10 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
// a handful of methods, so swap to it only if the method set grows.
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what OpenRig needs on either
// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent
// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve).
// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows.
export interface JsonRpcErrorObject {
code: number;
@@ -33,27 +34,18 @@ export type NotificationHandler = (params: unknown) => void;
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
export class StdioJsonRpcPeer {
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
export class JsonRpcPeer {
private nextId = 0;
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private ended = false;
private readonly lines: ReturnType<typeof createInterface>;
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
protected readonly lines: ReturnType<typeof createInterface>;
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input: child.stdout });
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input });
this.lines.on("line", (line) => this.receive(line));
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
request(method: string, params: unknown): Promise<unknown> {
@@ -77,25 +69,18 @@ export class StdioJsonRpcPeer {
this.notificationHandlers.set(method, handler);
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
/** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */
protected close(): void {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
}
private send(packet: Record<string, unknown>): void {
this.record("out", packet);
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
this.output.write(`${JSON.stringify(packet)}\n`);
}
private receive(line: string): void {
@@ -127,7 +112,7 @@ export class StdioJsonRpcPeer {
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
const handler = this.requestHandlers.get(method);
if (!handler) {
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
return;
}
try {
@@ -135,7 +120,48 @@ export class StdioJsonRpcPeer {
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
} catch (error) {
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
const data = (error as { data?: unknown }).data;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } });
}
}
}
/** The client end of an ACP agent process OpenRig launched. */
export class StdioJsonRpcPeer extends JsonRpcPeer {
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
super(child.stdout, child.stdin, record);
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.close();
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
}
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
export class StreamJsonRpcPeer extends JsonRpcPeer {
readonly closed: Promise<void>;
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
super(input, output, record);
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
}
}
+4 -3
View File
@@ -9,9 +9,10 @@ export const RUN_MARKER = "OPENRIG_ACP_RUN";
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
export interface RunProcess { pid: number }
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
const marker = `${RUN_MARKER}=${runId}`;
/** The caller's processes carrying this run's marker (`rig exec` names its connector's own). Linux reads /proc;
* elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string, markerName = RUN_MARKER): Promise<RunProcess[]> {
const marker = `${markerName}=${runId}`;
if (process.platform === "linux") {
const found: RunProcess[] = [];
for (const entry of await readdir("/proc")) {
+13
View File
@@ -62,3 +62,16 @@ export function writeTextAtomically(path: string, content: string, label = "file
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
}
}
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
export function writeNewText(path: string, content: string): void {
const fd = openSync(path, "wx", 0o666);
try {
writeFileSync(fd, content, "utf-8");
} catch (error) {
try { unlinkSync(path); } catch { /* Report the write failure. */ }
throw error;
} finally {
closeSync(fd);
}
}
+2 -1
View File
@@ -12,7 +12,8 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
module.enableCompileCache?.();
// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit.
if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.();
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
+83 -24
View File
@@ -3,8 +3,9 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
export function terminalAuthHeaders(): Record<string, string> {
const token = resolveTerminalToken();
@@ -146,9 +147,15 @@ interface DaemonRequestOptions {
interface DaemonClientOptions {
ownerProbe?: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
fetchImpl?: typeof fetch;
/** For a request allowed FETCH_HEADERS_TIMEOUT_MS or longer, which fetch would cut; default: fetchImpl when given, else
* node:http. */
longFetchImpl?: typeof fetch;
timeoutMs?: number;
/** The seat this client acts as, for a client built per authenticated caller; default: the process env. */
sessionName?: string;
/** With sessionName: the caller whose grant the MCP adapter checked, sent as X-OpenRig-Principal (JSON of its source,
* id and, for a grant-service grant, grant id and version; never a credential). */
principal?: { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
}
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
@@ -170,6 +177,7 @@ function localDaemonUrl(): string | undefined {
export class DaemonClient {
readonly baseUrl: string;
private fetchImpl: typeof fetch = fetch;
private longFetchImpl: typeof fetch = nodeHttpFetch;
private timeoutMs = 5_000;
private ownerProbe: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
/** Set by remoteDaemonClient; absent origin is labelled explicitly on remote requests. */
@@ -178,36 +186,85 @@ export class DaemonClient {
private identity?: Promise<Record<string, string>>;
private originUnknown = false;
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
/** Isolated terminal tools require this home's durable identity, even on loopback. */
async isLocalInstance(): Promise<boolean> {
if (this.remoteTarget) return false;
const origin = (await import("./local-origin.js")).readLocalOrigin();
return !!origin && await this.matchesInstanceIdentity(origin);
}
private async matchesInstanceIdentity(origin: string): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
return target === origin;
} catch { return false; }
finally { clearTimeout(timer); controller.abort(); }
}
private async identityHeaders(): Promise<Record<string, string>> {
const { headers, local } = await this.senderHeaders();
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
return { ...headers, "X-OpenRig-Principal": principal };
}
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
private async endpointIsThisHost(): Promise<boolean> {
if (this.remoteTarget) return false;
// Imported here, not at the top: it loads better-sqlite3 and yaml.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
return !!origin && (await this.endpointSelfHostId()) === origin;
}
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
private async endpointSelfHostId(): Promise<unknown> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
} catch { return undefined; }
finally { clearTimeout(timer); controller.abort(); }
}
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
* endpoint answered with this host's own instance id. */
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
const session = localHeaders[SENDER_IDENTITY_HEADER];
if (!session || session.split("@").length >= 3) return localHeaders;
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (!this.remoteTarget && !directTarget) return localHeaders;
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
if (!origin) {
this.originUnknown = true;
return { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
}
if (!this.remoteTarget) {
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
if (target === origin) return localHeaders;
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
finally { clearTimeout(timer); controller.abort(); }
}
return senderIdentityHeaders(origin, this.sessionName);
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality; unproved, the sender carries
// the known origin, never the endpoint's identity.
if (!this.remoteTarget && (await this.endpointSelfHostId()) === origin) return { headers: localHeaders, local: true };
return { headers: senderIdentityHeaders(origin, this.sessionName), local: false };
}
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
@@ -222,7 +279,7 @@ export class DaemonClient {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -239,9 +296,11 @@ export class DaemonClient {
}
this.fetchImpl = options?.fetchImpl ?? fetch;
this.longFetchImpl = options?.longFetchImpl ?? options?.fetchImpl ?? nodeHttpFetch;
this.ownerProbe = options?.ownerProbe ?? ((url, timeoutMs) => assessLocalDaemonOwner(url, { fetchImpl: this.fetchImpl, timeoutMs }));
this.timeoutMs = options?.timeoutMs ?? 5_000;
this.sessionName = options?.sessionName;
this.principal = options?.principal;
}
/** Shared with raw CLI data readers. Constructor URLs alone do not establish explicit intent. */
@@ -326,7 +385,7 @@ export class DaemonClient {
let responseStatus: number | undefined;
try {
const response = await fetchWithTimeout(
this.fetchImpl,
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
+186 -5
View File
@@ -4,16 +4,20 @@ import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DaemonClient, type DaemonResponse } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
import { processIdentity } from "../local-daemon-owner.js";
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
import { findRunProcesses } from "../acp/run-processes.js";
import { serveAcpAgent, SESSION_TAG, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js";
import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js";
import { CLI_VERSION } from "../version.js";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
export const runsDir = () => join(getOpenRigHome(), "acp", "runs");
export const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
@@ -44,6 +48,127 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue {
};
}
/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */
export function daemonTeamQueue(client: DaemonClient): TeamQueue {
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
return response.data;
};
const face = (data: Record<string, unknown>) => {
const row = (data.item ?? data) as Record<string, unknown>;
const text = (value: unknown) => (typeof value === "string" ? value : null);
return {
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution),
closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession),
};
};
const list = async (query: string, what: string): Promise<Array<Record<string, unknown>>> => {
const data = ok(await client.get<unknown>(`/api/queue/list?${query}`), what);
return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<Record<string, unknown>>;
};
/** The rows that ask the person for a decision (questions with choices, or a decision in words). */
const asks = (rows: Array<Record<string, unknown>>): TeamAsk[] =>
rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({
qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), state: String(row.state), tsCreated: String(row.tsCreated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "",
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [],
// A click records the option's id; a typed reply records its words.
answers: Object.fromEntries(Object.entries((row.humanAnswers as Record<string, unknown> | null) ?? {})
.map(([id, value]) => [id, typeof value === "string" ? value : String((value as { text?: unknown } | null)?.text ?? "")])),
}));
return {
async create(row) {
const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags });
// A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer.
if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
ok(response, `queue create for ${row.destination}`);
},
async show(qitemId) {
return face(ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`));
},
async find(qitemId) {
const response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`);
if (response.status === 404) return null;
return face(ok(response, `queue show ${qitemId}`));
},
async transitions(qitemId) {
const data = ok(await client.get<Array<Record<string, unknown>>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`);
return data.map((t) => ({
transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state),
transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession),
}));
},
async successor(qitemId) {
return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined;
},
async openAsks(person) {
return asks(await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`));
},
async sessionAsks(person, sessionId) {
const rows = await list(`destinationSession=${encodeURIComponent(person)}&limit=2000`, `rows to ${person}`);
return asks(rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes(`${SESSION_TAG}${sessionId}`)));
},
async answer(qitemId, questionId, optionId) {
// The person's answer path, as a click in Slack records one: the daemon takes the actor from this client's caller.
const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN;
const response = await client.post<Record<string, unknown>>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId },
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined);
if (response.status < 400) return { ok: true };
const data = (response.data ?? {}) as { message?: unknown; error?: unknown };
return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) };
},
async sent(caller) {
const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`);
return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({
qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[],
}));
},
};
}
/** The seat that takes this agent's tasks: --to, else this machine's acp.to setting (an ACP registry launch has no args).
* A setting that can't be read comes back as the reason, for the app's sign-in step to show. */
export function acpReceiver(to: string | undefined, store: Pick<ConfigStore, "get"> = new ConfigStore()): { receiver?: string } | { error: string } {
if (to?.trim()) return { receiver: to.trim() };
try {
return { receiver: String(store.get("acp.to") ?? "").trim() || undefined };
} catch (error) {
return { error: `${(error as Error).message} The agent reads acp.to from it; adding --to <member@rig> to the agent's command skips it.` };
}
}
/** Why the team's questions can't reach `address`, when it's a person this machine's OpenRig doesn't know. Says nothing
* for a seat's address, a registered person, or a registry that can't be read (unknown isn't unregistered). */
export async function unregisteredPersonNotice(address: string, home?: string): Promise<string | undefined> {
const suffix = "@external";
if (!address.endsWith(suffix)) return undefined;
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans(home);
if (!people.ok || people.entities.some((person) => person.address === address)) return undefined;
} catch {
return undefined;
}
const id = address.slice(0, -suffix.length);
return `Your team can't ask you questions here yet: ${address} isn't a registered person on this machine, so OpenRig refuses a question addressed to it. Your work is still sent. To be asked, register yourself with \`rig gateway human add ${id} --display-name "<your name>" --delivery-class <A|B|C|D>\`.`;
}
/** The person this machine's OpenRig names as its default (`<id>@external`), or why there isn't one. */
async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> {
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans();
if (!people.ok) return { reason: people.error };
const chosen = registry.resolveDefaultPerson(people.entities);
return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" };
} catch (error) {
return { reason: (error as Error).message };
}
}
function readRun(runId: string): RunFile | undefined {
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
}
@@ -62,7 +187,7 @@ function liveConnector(run: RunFile): boolean {
}
export function acpCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const connect = async (): Promise<DaemonClient | undefined> => {
const deps = getDeps();
@@ -94,7 +219,7 @@ Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second C
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
Example:
rig acp run qitem-20261023-abc --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
@@ -135,6 +260,62 @@ Guide: rig context get reference/acp-agents.md`)
}
});
cmd
.command("serve")
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed and others) to launch: its prompts go to your team as queue rows")
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (default: rig config get acp.to)")
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
.addHelpText("after", `
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
Without --to, the receiver is the acp.to setting (rig config set acp.to <member@rig>), which is how an app that
starts the agent from the ACP registry, with no arguments, reaches your team.
It uses the same OpenRig daemon as rig: the one on this computer, started with rig daemon start, unless OPENRIG_URL
names another. The app's sign-in step ("OpenRig on this machine") checks that the daemon answers and that you
resolve to an address: --as, OPENRIG_SESSION_NAME, or the default person: the only one registered
(rig gateway human add <id> ...), or with several, the one chosen with rig gateway default <id>.
In Zed's settings.json:
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
console.log = console.error;
console.info = console.error;
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
const status = await getDaemonStatus(getDeps().lifecycleDeps);
if (!daemonMayAnswer(status)) {
const err = statusGuardMessage(status);
return { error: `${err.fact} ${err.action}` };
}
let caller = opts.as?.trim() || self()?.trim();
if (!caller) {
const person = await defaultPersonAddress();
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Register yourself with rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D> (the only registered person is the default; with several, choose one with rig gateway default <id>), or add --as <address> to the agent's command.` };
caller = person.address;
}
const target = acpReceiver(opts.to);
if ("error" in target) return target;
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: target.receiver, notice: await unregisteredPersonNotice(caller) };
};
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
serveAcpAgent(peer, {
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
});
await peer.closed;
});
cmd
.command("cancel <runId>")
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
+3 -1
View File
@@ -45,7 +45,7 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.postText<{ valid?: boolean; errors?: string[] }>("/api/agents/validate", yaml);
const res = await client.postText<{ valid?: boolean; errors?: string[]; advisories?: string[] }>("/api/agents/validate", yaml);
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -65,6 +65,8 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
}
const data = res.data;
// Advisories are printed whether or not the spec is valid; they never make it invalid.
for (const advisory of data.advisories ?? []) console.error(`⚠ agent spec advisory: ${advisory}`);
if (data.valid) {
const name = yamlScalar(yaml, "name") ?? "unknown";
const version = yamlScalar(yaml, "version") ?? "unknown";
+18 -12
View File
@@ -11,26 +11,31 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).replace(/\r?\n$/, "");
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string; tmuxSocketPath?: string } {
const tmuxPane = process.env["TMUX_PANE"];
if (!tmuxPane) {
return { attachmentType: "external_cli" };
}
try {
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
if (tmuxSession && resolvedPane) {
return {
attachmentType: "tmux",
tmuxSession,
tmuxWindow: tmuxWindow || undefined,
tmuxPane: resolvedPane,
};
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
// Read the socket separately: tmux replaces control-character delimiters,
// and session names and socket paths may both contain literal spaces.
const tmuxSocketPath = tmuxExec(`tmux display-message -p -t ${shellQuote(resolvedPane!)} '#{socket_path}'`).replace(/\r?\n$/, "");
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane!,
...(tmuxSocketPath ? { tmuxSocketPath } : {}) };
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
@@ -57,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.action(async (opts: {
@@ -119,6 +124,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
body["tmuxSession"] = attachContext.tmuxSession;
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
body["tmuxPane"] = attachContext.tmuxPane;
if (attachContext.tmuxSocketPath) body["tmuxSocketPath"] = attachContext.tmuxSocketPath;
}
if (opts.displayName) body["displayName"] = opts.displayName;
+6 -2
View File
@@ -222,12 +222,16 @@ async function runCrossHostBroadcast(
* local route does, so per-target honesty prints identically. A partial
* fan-out sets a non-zero exit either way. */
function renderBroadcastResults(data: Record<string, unknown>): void {
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? [];
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? [];
for (const r of results) {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) {
// The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`);
} else {
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
// A target that resolved to no sessions has no name to report.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
}
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
}
+73 -7
View File
@@ -26,6 +26,8 @@ interface CaptureOpts {
host?: string;
json?: boolean;
historyPlusPane?: boolean;
readable?: boolean;
size?: string;
/** --lines was given on the command line (not the "20" default). */
linesGiven?: boolean;
/** Set when --lines N was given (and --history-plus-pane wasn't): the output is cut to the last N lines. */
@@ -44,6 +46,30 @@ function exactLineCount(opts: CaptureOpts, linesFromCli: boolean): number | unde
return n > 0 ? n : undefined;
}
/** How long a readable capture request may take: the daemon's 8 s for readable reads (CAPTURE_READABLE_BUDGET_MS, shared
* across a fan-out), its 1 s for input-box reads, and the client's usual 5 s for the rest. */
export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
/** `--readable` reads at 120x40; `--size WxH` at that size. Null when neither was given; an error for a bad --size. */
/** What happened to the window after a readable read, as far as OpenRig knows. */
export function readableRelease(release: string | undefined): string {
switch (release) {
case "given-back": return "its window was then given back its size and its own window-size";
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
default: return "its window's record had changed, so OpenRig left the window as it found it";
}
}
export function readableSize(opts: Pick<CaptureOpts, "readable" | "size">): { width: number; height: number } | null | { error: string } {
if (opts.size === undefined) return opts.readable ? { width: 120, height: 40 } : null;
const match = /^(\d{1,4})x(\d{1,4})$/.exec(opts.size);
const width = match ? Number(match[1]) : 0;
const height = match ? Number(match[2]) : 0;
if (!match || width < 1 || height < 1 || width > 1000 || height > 1000) return { error: `--size wants WIDTHxHEIGHT, each from 1 to 1000 (got ${JSON.stringify(opts.size)})` };
return { width, height };
}
/**
* The last `n` lines of a capture. The blank rows of the visible pane below its content are dropped first: on a
* short shell pane the bottom rows are empty, and the last N rows would otherwise be all blank.
@@ -92,6 +118,14 @@ function renderCapture(data: Record<string, unknown>): void {
// A prompt in a pane too small to show it: say so, with the resize command, rather than leave a reader guessing.
const pane = r["promptPane"] as { hint?: string } | undefined;
if (pane && typeof pane.hint === "string") console.error(`[rig capture: ${r["sessionName"]}: ${pane.hint}]`);
// --readable: the size it was read at, or why the window was left as it was.
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
if (readable?.resized === true) {
const after = readable.release === "given-back" && readable.sizeBack
? `its own window-size was given back, but ${readable.sizeBack}`
: readableRelease(readable.release);
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
};
// Multi-target result
@@ -130,6 +164,8 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Show the last N lines of the pane (without it: 20 history lines plus the whole visible pane)", "20")
.option("--history-plus-pane", "With --lines N, show N history lines plus the whole visible pane, as before --lines was exact")
.option("--readable", "Read a seat whose pane is smaller than 120x40 at that size, then give its window back")
.option("--size <WxH>", "With or without --readable: read at this size instead (for example 160x50)")
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
@@ -137,6 +173,7 @@ Examples:
rig capture dev-impl@my-rig
rig capture dev-impl@my-rig --lines 50
rig capture dev-impl@my-rig --lines 5 --history-plus-pane
rig capture dev-impl@my-rig --readable
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json
@@ -151,8 +188,15 @@ Supported notes:
suggestion regions on the visible screen (zero-based rows and columns), evidence and hint.
- When the captured screen is at a prompt and the seat's pane is smaller than 80x24, a
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
cut off and gives the tmux resize-window command that makes it readable. --json has it as
promptPane: width, height and hint.
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
window, waits for the seat to redraw, captures, then puts the window back, both its size and
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
are. A stderr note says which; --json has it as readable.
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
above them are left out, a "[rig capture: ...]" note on stderr says how many; --json reports
lines (returned), requestedLines and omittedLines. Trailing rows that are entirely whitespace
@@ -185,6 +229,12 @@ Supported notes:
const deps = getDeps();
opts.linesGiven = cmd.getOptionValueSource("lines") === "cli";
opts.exactLines = exactLineCount(opts, opts.linesGiven);
const size = readableSize(opts);
if (size && "error" in size) {
console.error(size.error);
process.exitCode = 1;
return;
}
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
@@ -215,14 +265,18 @@ Supported notes:
const client = deps.clientFactory(getDaemonUrl(status));
const lines = parseInt(opts.lines ?? "20", 10);
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
const body: Record<string, unknown> = { lines: requestedLines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
body.inputBox = true; // the input box note; a daemon without it ignores the field
if (size) body.readable = size; // a daemon without it ignores the field and captures as before
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body, { headers: terminalAuthHeaders() });
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body,
{ headers: terminalAuthHeaders(), ...(size ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {}) });
if (res.status < 400) res.data = withExactLines(res.data, opts.exactLines);
if (opts.json) {
@@ -282,8 +336,16 @@ async function runCrossHostCapture(
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
// Only an explicit --lines is forwarded: the remote's own default stays the default.
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
// A value that parses as a positive count goes over as given (the remote decides);
// only a missing, zero, negative or non-numeric count sends the documented default.
if (opts.linesGiven || opts.historyPlusPane) {
const given = opts.lines ?? "20";
const parsed = parseInt(given, 10);
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
}
if (opts.historyPlusPane) argv.push("--history-plus-pane");
if (opts.readable) argv.push("--readable");
if (opts.size !== undefined) argv.push("--size", opts.size);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
@@ -321,13 +383,17 @@ async function runHttpHostCapture(
hint?: string,
): Promise<void> {
const lines = parseInt(opts.lines ?? "20", 10);
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
const body: Record<string, unknown> = { lines: requestedLines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
body.inputBox = true;
const size = readableSize(opts);
if (size && !("error" in size)) body.readable = size;
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps,
body.readable ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {});
if (result.ok) result.data = withExactLines(result.data, opts.exactLines);
if (opts.json) {
+23 -4
View File
@@ -4,7 +4,24 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
* as a newline or tab), so `-P -F` prints the name tmux used. */
export function chatroomWatchTmuxArgs(rig: string): string[] {
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
}
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
function tmuxArg(value: string): string {
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
}
interface RigSummary {
id: string;
@@ -175,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.tmux) {
const sessionName = `chatroom@${rig}`;
try {
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
console.log(`Started watch in tmux session: ${sessionName}`);
console.log(`Attach with: tmux attach -t ${sessionName}`);
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
process.exitCode = 1;
+19 -4
View File
@@ -52,10 +52,25 @@ from --state, asking it to skip reading if it did not compact; a retry would com
An explicit retry still waits for the live pane to be idle; no automatic retry follows
cancellation. --skip-map skips only the map requirement for this one
manual request. Cancel an active preparation before retrying or using --skip-map.
Automatic preparation waits at most 25 minutes from prep delivery, using existing
polls. A stopped attempt does not restart automatically; rerun this command to
start a fresh attempt. Cancellation disarms future input; it cannot undo a command
already submitted. Raw provider /compact and normal messages are unchanged.`);
Automatic preparation allows 25 minutes from prep delivery for the map. A completed
map stays armed for idle through the next retry interval (10 minutes by default),
without another preparation prompt; --state includes idleDeadlineAt. A completed
automatic map whose /compact is refused before input (a draft in the box, the pane in
a tmux mode) or that never reaches idle in that time is held instead: --state shows
prepared with preparedAt and preparedReason, and /compact goes in at the next idle
moment with an empty box, with the same map and no new preparation prompt. A held
attempt stops on a fresh sample below the threshold or from another conversation
(below_threshold_while_prepared), once usage has risen 15 points since its request
or two hours after its map was ready (prepared_map_stale), or when its map no longer
reads ready (preparation_incomplete).
After an attempt stops with its request delivered, the next automatic request for the
same conversation waits for an idle seat, across a daemon restart too, for up to the
restart-recovery window (6 hours by default) after that attempt stopped. An unfinished
map reports preparation_incomplete. Manual requests still
share one 180-second budget and stop as before; while an attempt is held, a manual
request reads already_in_progress until --cancel.
Stopped attempts may retry automatically after the cooldown; cancellation does not.
Cancellation disarms future input; it cannot undo a command already submitted. Raw provider /compact and normal messages are unchanged.`);
const getDepsF = (): CompactDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
+47 -8
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -55,7 +55,7 @@ Examples:
rig config init-workspace # scaffold a repo-ready project workspace
Keys:
daemon.* port, host
daemon.* port, host, seat_poll_interval_ms (startup-only seat-pollers cadence, 1000–2000; default 1000)
db.path
transcripts.* enabled, path, lines, poll_interval_seconds
(lines/poll_interval set scrollback CAPTURE depth + cadence.
@@ -69,6 +69,7 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
@@ -96,16 +97,30 @@ Keys:
wake_retry_interval_seconds, wake_retry_cap, wake_unconfirmed_window_minutes,
wake_swap_grace_seconds (S01 wake-or-escalate ladder),
wake_human_rung (always | explicit-only — post agent-to-agent escalations to a person;
under explicit-only, an ask that needs a person must be addressed to the person;
without a human registry, explicit-only posts everything, as always does)
explicit-only posts rows addressed to a person; other agent work stays visible,
permission prompts still reach a person,
choice prompts follow this setting; unavailable human delivery stays a visible row;
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
the telemetry export, the log and the feed, and can't be brought back. Read at the next sweep
(daily and at daemon start)
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
launch.codex_login_shell defer to Codex's own login-shell setting in managed Codex seats (default off)
Off adds allow_login_shell=false: tool commands inherit the seat's environment, so plain rig
works wherever OpenRig is installed. Inherited setup isn't re-run per command: setup only in
login or rc files (including a tmux default-command that isn't a login shell) and shell
functions or aliases may be absent. On omits that override; it doesn't force login shells,
and a login file that sets PATH outright can then hide rig. Applies at each seat's next
launch; a restart in place keeps the running choice.
terminal.inherit_tmux_environment inherit stored tmux global values for new agents and successors (default off)
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
terminal.status_bar show the inner tmux status bar on launch (default off)
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
To keep more: rig config set terminal.history_limit 50000, then rig setup. A running tmux server keeps
its old limit until it restarts or you run tmux source-file ~/.tmux.conf; before tmux 3.7 that changes new panes only
@@ -119,10 +134,13 @@ Keys:
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. A draft found only by reading the input box may leave that retry waiting for the seat's next activity change or the stuck sweep, rather than the moment the draft clears. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
decisions.template_dir absolute folder of <name>.yaml templates that replace the shipped ones (default empty: shipped templates only)
acp.to the seat (member@rig) that receives tasks from rig acp serve when it isn't given --to, as when an ACP app
starts it from the ACP registry (default empty: none, so nothing is sent until one is set)
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
@@ -189,10 +207,18 @@ Precedence: CLI flag > environment variable > config file > default`)
.action(async (key: string, value: string) => {
try {
store.set(key, value);
console.log(`${key} = ${store.get(key)}`);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
: resolved.source === "default"
? `${key} file override saved; effective value ${resolved.value} (source: default)`
: `${key} = ${resolved.value}`);
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
console.error(resolved.source === "env"
? `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; the saved value remains masked after restart while the environment override is set.`
: `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
}
} catch (err) {
console.error((err as Error).message);
@@ -207,9 +233,22 @@ Precedence: CLI flag > environment variable > config file > default`)
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
: `${key} reset to default (${resolved.value}).`);
} else {
console.log("Config reset to defaults.");
let envKeys: string[] = [];
try {
envKeys = Object.entries(store.resolveAllWithSource())
.filter(([, resolved]) => resolved.source === "env")
.map(([name]) => name);
} catch (err) {
console.error(`note: could not read effective configuration after reset: ${(err as Error).message}`);
}
console.log(envKeys.length
? `Config reset to defaults, except where the environment overrides them: ${envKeys.join(", ")}.`
: "Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
@@ -0,0 +1,69 @@
import { Command } from "commander";
import { join, resolve } from "node:path";
import { ConfigStore } from "../config-store.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { resolveWorkContextHome } from "../lib/work-install.js";
import { ReconstructionError, readReconstruction, readStoreMappings, reconstructionOutput } from "../lib/reconstruction-reader.js";
interface Options {
store?: string; storeMap?: string; project?: string; mission?: string; slice?: string; cwd?: string;
item: string[]; category: string[]; tag: string[]; checkSources?: boolean; maxBytes: string; json?: boolean;
}
const collect = (value: string, previous: string[]) => [...previous, value];
/** A local config/filesystem consumer. This command never starts or contacts a
* daemon, invokes a helper, writes revisions, or takes over the selected work. */
export function reconstructionCommand(): Command {
const cmd = new Command("reconstruction")
.description("Inspect or read an exact immutable reconstruction locally (read-only v0 consumer)");
for (const mode of ["inspect", "view"] as const) {
cmd.command(`${mode} <revision>`)
.description(mode === "inspect" ? "Inspect provenance, dates, size and the complete source manifest" : "Read a faithful selected view with dependencies and explicit omissions")
.option("--store <absolute-path>", "Explicit local v0/import store; default: selected work home/reconstructions")
.option("--store-map <file>", "JSON array of {sourceStore, localStore, sourceHost?}; read-time mapping only")
.option("--project <id>", "Catalog project, with the same inference as context work-install")
.option("--mission <id>", "Mission in the selected project's configured missions root")
.option("--slice <id>", "Slice under the selected mission")
.option("--cwd <path>", "Working directory used for project inference")
.option("--item <id>", "Include item (repeatable; selectors form a union)", collect, [])
.option("--category <name>", "Include category (repeatable)", collect, [])
.option("--tag <name>", "Include tag (repeatable)", collect, [])
.option("--check-sources", "Hash declared local files within 128 files / 64 MiB; never refresh observation dates")
.option("--max-bytes <bytes>", "Maximum complete output, 1024..8388608 UTF-8 bytes", "262144")
.option("--json", "Structured output; inspect always uses JSON")
.addHelpText("after", "\nSupports openrig.reconstruction/v0 with safe integer JSON numbers; other numeric representations are explicitly unsupported. No implicit remote reads. Context does not transfer authority.\n")
.action((revision: string, opts: Options) => {
try {
let store = opts.store;
let resolution: object = { mode: "explicit-store", project: null, home: null };
if (store === undefined || opts.project !== undefined || opts.mission !== undefined || opts.slice !== undefined) {
const config = new ConfigStore();
const home = resolveWorkContextHome({
workspaceRoot: String(config.resolveWithSource("workspace.root").value),
catalogPath: String(config.resolveWithSource("workspace.catalog_path").value),
cwd: resolve(opts.cwd ?? process.cwd()),
...(readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME") ? { sessionName: readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")! } : {}),
...(opts.project === undefined ? {} : { project: opts.project }),
...(opts.mission === undefined ? {} : { mission: opts.mission }),
...(opts.slice === undefined ? {} : { slice: opts.slice }),
});
if ("error" in home) throw new ReconstructionError(home.error.code, home.error.message);
resolution = { mode: store === undefined ? "work-home" : "explicit-store", ...home };
store ??= join(home.home, "reconstructions");
}
const result = readReconstruction({ store, revision,
...(opts.storeMap ? { mappings: readStoreMappings(resolve(opts.storeMap)) } : {}),
items: opts.item, categories: opts.category, tags: opts.tag, checkSources: opts.checkSources });
const response = { ...result, resolution };
console.log(reconstructionOutput(response, mode, Boolean(opts.json), Number(opts.maxBytes)));
if (!result.complete) process.exitCode = 1;
} catch (error) {
const code = error instanceof ReconstructionError ? error.code : "reconstruction_unavailable";
if (opts.json || mode === "inspect") console.log(JSON.stringify({ complete: false, error: { code, message: (error as Error).message } }, null, 2));
else console.error(`${code}: ${(error as Error).message}`);
process.exitCode = 1;
}
});
}
return cmd;
}
+4
View File
@@ -43,6 +43,7 @@ import { realDeps } from "./daemon.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import type { StatusDeps } from "./status.js";
import { reconstructionCommand } from "./context-reconstruction.js";
const contextRuntimeArg = enumArg(["claude-code", "claude", "codex"]);
@@ -194,6 +195,7 @@ async function installPackFromUrl(
// Re-validate the on-disk staged pack, then publish atomically.
validateContextPackManifestForInstall(join(staging, "manifest.yaml"));
assertTreeHasNoSymlinks(staging);
mkdirSync(dirname(targetDir), { recursive: true });
renameSync(staging, targetDir);
return { targetDir, installName };
} catch (err) {
@@ -251,6 +253,8 @@ Examples:
rig context trace --rig factory --pod dev --seat dev-qa --name LEARNED.md
`);
cmd.addCommand(reconstructionCommand());
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
+11 -5
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { getOpenRigHome } from "../openrig-compat.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
@@ -54,6 +54,14 @@ function existsOrThrow(path: string): boolean {
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
}
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
return openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
}
/**
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
async function realEmit(): Promise<CrashCartEmit> {
const cc = await import("@openrig/daemon/crash-cart");
const home = getOpenRigHome();
const openrigUrl = process.env.OPENRIG_URL;
const openrigUrl = readOpenRigUrl();
const probeClassified = (url: string) =>
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
const detectDeps = {
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
? `alive (pid ${state.pid})`
: `dead (pid ${state.pid})`
: "no daemon.json";
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const url = crashCartEvidenceUrl(openrigUrl, state);
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+16 -4
View File
@@ -57,10 +57,12 @@ export function createProcessLiveness(deps: {
readProcessState: (pid: number) => string | null;
}): (pid: number) => ProcessLiveness {
return (pid: number) => {
if (deps.signal(pid) === "missing") return "dead";
const signal = deps.signal(pid);
if (signal === "missing") return "dead";
const state = deps.readProcessState(pid)?.trim();
if (!state) return "unknown";
return state.startsWith("Z") ? "dead" : "alive";
if (state.startsWith("Z")) return "dead";
return signal === "not-permitted" ? "unknown" : "alive";
};
}
@@ -264,7 +266,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
@@ -272,6 +274,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else if (result.kernelState === "skipped") {
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
console.error(skippedKernelMessage(result.detail));
process.exitCode = 1;
} else {
// Honest 3-part error per banked discipline.
console.error(
@@ -341,13 +347,19 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
console.log("Daemon PID is absent (stale state)");
break;
case "unverified":
// T2: a live start reservation names a launch in progress (listener not up yet).
if (status.startPending) {
const since = status.startPending.since ? ` since ${status.startPending.since}` : "";
console.log(`Daemon starting: launch reserved by pid ${status.startPending.pid}${since}; not listening yet`);
}
// 1ae863d2 — C3 semantics: we could NOT confirm up or down; never claim stopped.
if (status.siblingHint) {
console.log("Daemon state UNVERIFIED — the resolved OPENRIG_HOME has no daemon state, but a live daemon appears under a sibling home:");
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
console.log(` sibling: ${status.siblingHint.siblingHome}`);
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
} else {
} else if (!status.startPending) {
// A pending start's probe was refused, not inconclusive; its line above says what is known.
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
console.log(" Re-check with: rig daemon status · direct: curl the daemon /healthz");
}
+2
View File
@@ -18,6 +18,7 @@ import {
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
import { readRecordedTmuxServerArgs } from "../tmux-attachment.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
@@ -116,6 +117,7 @@ function realDestroyDeps(): DestroyDeps {
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
resolveTmuxServerArgs: readRecordedTmuxServerArgs,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
+223 -22
View File
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { DaemonClient, ForeignLocalDaemonError, formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
@@ -13,11 +14,12 @@ import {
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
import { parse as parseYaml } from "yaml";
import { compareSpecToLive, topologyFromRigSpec, topologyFromLiveLogicalIds } from "@openrig/daemon/spec-conformance";
import { classifyNodeVersion } from "../node-support.js";
import { checkProviderReadiness } from "../provider-auth.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
interface DoctorCheck {
name: string;
@@ -31,7 +33,8 @@ export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
exec: (cmd: string) => string;
/** `timeoutMs` bounds one command; without it a command runs to completion, as before. */
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
checkPort: (port: number, host: string) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
@@ -39,7 +42,12 @@ export interface DoctorDeps {
env?: NodeJS.ProcessEnv;
mkdirp?: (path: string) => void;
checkWritable?: (path: string) => void;
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
fetch?: (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
/** The caller's effective user id, compared with the one the port's daemon reports. Absent: this process's own;
* null: this platform has none. */
callerUid?: number | null;
/** How long each part of /healthz and the cmux status response may take (default 2000 ms). */
ownerReportTimeoutMs?: number;
/**
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
*
@@ -54,8 +62,74 @@ export interface DoctorDeps {
}
const DEFAULT_PORT = 7433;
const DOCTOR_HTTP_TIMEOUT_MS = 2000;
const CMUX_SOCKET_CONTROL_FIX = 'In cmux Settings > Automation, select "Automation mode" for Socket Control Mode, or set automation.socketControlMode to "automation" in ~/.config/cmux/cmux.json (older versions use ~/.config/cmux/settings.json; current cmux reads that legacy file only for keys absent from cmux.json). cmux reloads saved settings automatically; rerun `rig doctor` to re-probe without restarting the daemon.';
/** The port's OpenRig daemon, judged by the OS user it reports. /healthz's pid and euid are the daemon's own report,
* not authentication, and every line says so. Another user's daemon warns, since a share set up through config alone
* looks the same and must keep working; it passes when OPENRIG_URL selects this endpoint on purpose. An owner that can't
* be compared, or a body that doesn't arrive in time, warns too. Nothing is stopped or refused here. */
async function listenerOwnerCheck(where: string, base: string, res: { json?: () => Promise<unknown> }, release: () => void,
deps: DoctorDeps): Promise<DoctorCheck> {
const env = deps.env ?? process.env;
const at = `Port ${where} (${base}, OpenRig home ${getOpenRigHome(env)})`;
const callerUid = deps.callerUid === undefined ? process.geteuid?.() : deps.callerUid ?? undefined;
const boundMs = deps.ownerReportTimeoutMs ?? 2000;
let reported: { pid?: unknown; euid?: unknown } = {};
let late = false;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const read = res.json ? res.json() : Promise.resolve(null);
// Releasing the request after the bound rejects the abandoned read; that's expected, not an error.
read.catch(() => {});
const body: unknown = await Promise.race([
read,
new Promise<null>(resolve => { timer = setTimeout(() => { late = true; resolve(null); }, boundMs); }),
]);
if (body && typeof body === "object") reported = body as { pid?: unknown; euid?: unknown };
} catch { /* no readable body: the owner stays unknown */ }
finally { clearTimeout(timer); release(); }
const pid = Number.isSafeInteger(reported.pid) && (reported.pid as number) > 0 ? `, pid ${reported.pid as number}` : "";
const euid = Number.isSafeInteger(reported.euid) && (reported.euid as number) >= 0 ? reported.euid as number : undefined;
const ownDaemon = "To run your own, give this OpenRig home a free port and start its daemon there: rig config set daemon.port <free-port>, then rig daemon start.";
if (euid === undefined || callerUid === undefined) {
return {
name: "port", status: "warn",
message: `${at} is in use by an OpenRig daemon whose OS user is unknown.`,
reason: late
? `Its /healthz report didn't finish within ${boundMs / 1000} s, so doctor can't tell whether it's yours or another user's on this machine.`
: euid === undefined
? "It doesn't report an effective user id, so doctor can't tell whether it's yours or another user's on this machine."
: "This platform has no effective user id to compare, so doctor can't tell whether it's yours.",
fix: `Check that it's yours with rig daemon status. ${ownDaemon}`,
};
}
const reports = `the endpoint reports euid ${euid}${pid}`;
if (euid === callerUid) return { name: "port", status: "pass", message: `${at} is in use by your OpenRig daemon: ${reports}, the same as yours.` };
const users = `another OS user: ${reports}; your euid is ${callerUid}`;
if (sameEndpoint(readOpenRigUrl(env), base)) {
return { name: "port", status: "pass", message: `${at} is the endpoint OPENRIG_URL selects, served by an OpenRig daemon of ${users}.` };
}
return {
name: "port", status: "warn",
message: `${at} is in use by an OpenRig daemon of ${users}.`,
reason: `It isn't this OpenRig home's daemon, unless that user shares it with you: a command that reaches ${base} acts in that user's OpenRig. The pid and euid are the daemon's own report on /healthz, a self-report rather than authentication.`,
fix: `${ownDaemon} To use a shared daemon on purpose, set OPENRIG_URL to its address.`,
};
}
/** Whether OPENRIG_URL names this endpoint: the same scheme, host and port, with localhost read as 127.0.0.1. */
function sameEndpoint(url: string | undefined, base: string): boolean {
if (!url) return false;
try {
const [a, b] = [new URL(url), new URL(base)];
const host = (u: URL) => u.hostname === "localhost" ? "127.0.0.1" : u.hostname;
return a.protocol === b.protocol && a.port === b.port && host(a) === host(b);
} catch {
return url === base;
}
}
function defaultCheckPort(port: number, host: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
@@ -128,6 +202,16 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
});
} else if (tmuxProbe.available && tmuxProbe.version) {
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
if (capture.status === "fail") {
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
checks.push({ name: "tmux_capture", status: "fail", message: failure.message, reason: failure.reason, fix: failure.fix });
} else {
checks.push(capture.status === "pass"
? { name: "tmux_capture", status: "pass", message: "tmux can capture a pane (checked on a throwaway server)." }
: { name: "tmux_capture", status: "skipped", message: `Couldn't start a throwaway tmux server to check capture-pane: ${capture.detail}` });
}
if (platform === "darwin") {
const mouseMode = readTmuxMouseMode(deps);
if (mouseMode === "on") {
@@ -219,17 +303,32 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
const fetchFn = deps.fetch ?? globalThis.fetch;
const healthHeaders = async (controller: AbortController) => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
fetchFn(`${daemonBase}/healthz`, { signal: controller.signal }),
new Promise<null>(resolve => { timer = setTimeout(() => { resolve(null); controller.abort(); }, deps.ownerReportTimeoutMs ?? 2000); }),
]);
} finally { clearTimeout(timer); }
};
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} available.` };
}
// Port in use — check if it's our daemon via healthz
// Port in use — check if it's an OpenRig daemon via healthz, and whose.
const owner = new AbortController();
try {
const res = await fetchFn(`${daemonBase}/healthz`);
if (res.ok) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} in use by OpenRig daemon.` };
}
const res = await healthHeaders(owner);
if (!res) return {
name: "port", status: "warn",
message: `Port ${daemonHost}:${daemonPort} is in use by an unknown listener.`,
reason: "Its /healthz response headers did not arrive in time, so doctor cannot identify it.",
fix: `Check the listener on port ${daemonPort} or configure a free port for the daemon.`,
};
if (res.ok) return await listenerOwnerCheck(`${daemonHost}:${daemonPort}`, daemonBase, res, () => owner.abort(), deps);
} catch { /* not our daemon */ }
finally { owner.abort(); }
return {
name: "port",
status: "fail",
@@ -243,19 +342,27 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
const health = new AbortController();
try {
const healthRes = await fetchFn(`${daemonBase}/healthz`);
if (!healthRes.ok) {
const healthRes = await healthHeaders(health);
if (!healthRes?.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon healthz not ok. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
} finally { health.abort(); }
try {
const cmuxRes = await fetchFn(`${daemonBase}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
let data: { available?: boolean } = {};
const cmuxRes = await fetchWithTimeout(fetchFn as typeof fetch, `${daemonBase}/api/adapters/cmux/status`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's cmux status read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) data = (await res.json()) as { available?: boolean };
else await res.body?.cancel();
},
});
if (cmuxRes.ok) {
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
@@ -268,11 +375,72 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
asyncChecks.push(daemonCmuxCheck);
}
asyncChecks.push(buildTmuxControlModeCheck(deps, daemonBase));
asyncChecks.push(buildSpecConformanceCheck(deps));
return { checks, portCheck, asyncChecks };
}
type TmuxControlReport = { requested?: boolean; state?: "on" | "allowed" | "off" | "undecided"; serverVersion?: string };
/**
* #161 — does the daemon drive tmux through one control-mode client (OPENRIG_TMUX_CONTROL=1)? Asked for on a server
* older than tmux 3.7c, it uses a spawned client for every command, and nothing else says so.
*/
async function buildTmuxControlModeCheck(deps: DoctorDeps, daemonBase: string): Promise<DoctorCheck> {
const name = "tmux_control_mode";
let control = null as TmuxControlReport | null;
try {
await fetchWithTimeout((deps.fetch ?? globalThis.fetch) as typeof fetch, `${daemonBase}/api/adapters/tmux/control`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's tmux control mode read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) control = (await res.json()) as TmuxControlReport;
else await res.body?.cancel();
},
});
} catch { /* no daemon, or one from before this route */ }
if (!control) {
return { name, status: "skipped", message: "The daemon didn't say whether it uses tmux control mode (it isn't running, or it's older than this check)." };
}
if (!control.requested) {
if ((deps.env ?? process.env)["OPENRIG_TMUX_CONTROL"] !== "1") {
return { name, status: "skipped", message: "tmux control mode isn't asked for (OPENRIG_TMUX_CONTROL=1 in the daemon's environment)." };
}
return {
name, status: "warn",
message: "OPENRIG_TMUX_CONTROL=1 is set in this shell, but the running daemon wasn't started with it, so it spawns a tmux client for every command.",
fix: "Set OPENRIG_TMUX_CONTROL=1 in the daemon's own environment (the shell or service that starts it), then restart the daemon.",
};
}
const version = control.serverVersion;
if (control.state === "on") {
return { name, status: "pass", message: `tmux control mode is on: the daemon's tmux server runs tmux ${version}.` };
}
if (control.state === "undecided") {
return { name, status: "skipped", message: "tmux control mode is asked for, and the daemon hasn't read its tmux server's version yet. It does at its first tmux command while a session runs." };
}
if (control.state === "allowed") {
return {
name, status: "warn",
message: `tmux control mode is asked for and the server's tmux ${version} allows it, but no control client is connected right now, so tmux commands use a spawned client.`,
reason: "The daemon starts a control client at a tmux command while another session runs on the server. One that can't start or attach is tried again after a while, and commands are spawned meanwhile.",
};
}
if (!version) {
return { name, status: "warn", message: "tmux control mode is asked for but this daemon doesn't use a control client: it runs on Windows (psmux) or with OPENRIG_TMUX_SHELL=1." };
}
const known = /^\d+\.\d+[a-z]?$/.test(version);
return {
name, status: "warn",
message: known
? `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, and control mode needs tmux 3.7c or later.`
: `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, which isn't a tmux release known to be 3.7c or later.`,
reason: "Starting a control client can crash a tmux server older than 3.7c, so the daemon spawns a tmux client for every command instead. The running server's version decides this, not the tmux binary on PATH.",
fix: "Run the daemon's sessions on a tmux 3.7c or later server: rig context get reference/tmux-servers.md#moving-an-existing-team-onto-tmux-control-mode",
};
}
/**
* Build B — does the rig spec still describe the rig that is running?
*
@@ -316,6 +484,12 @@ async function buildSpecConformanceCheck(deps: DoctorDeps): Promise<DoctorCheck>
let liveIds: string[] | null;
try { liveIds = deps.fetchLiveLogicalIds ? await deps.fetchLiveLogicalIds(rigName) : null; }
catch (error) {
if (error instanceof AmbiguousLiveRigError) return {
name, status: "skipped",
message: `Multiple live rigs match '${rigName}': ${error.ids.join(", ")}; conformance unknown.`,
reason: "The spec name does not identify one live rig, so no topology was compared.",
fix: "Inspect the matching rig IDs with rig ps --json; use rig ps --nodes -A --json to inspect nodes.",
};
if (error instanceof ForeignLocalDaemonError) return { name, status: "fail", message: error.message };
throw error;
}
@@ -384,6 +558,12 @@ function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): Do
};
}
/** The standalone doctor's command runner. A command with `timeoutMs` is bounded (the tmux capture probe passes one); others
* run to completion, as before. */
export function doctorExec(c: string, opts?: { timeoutMs?: number }): string {
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}) });
}
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
try {
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
@@ -394,22 +574,43 @@ function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
}
}
class AmbiguousLiveRigError extends Error {
constructor(readonly ids: string[]) { super("Multiple live rigs share the spec name"); }
}
/** Raw topology reads retain their existing headers; the owner policy is shared with the client. */
export async function readLiveLogicalIds(base: string, rigName: string, ownerClient = new DaemonClient(base)): Promise<string[] | null> {
try {
await ownerClient.assertLocalOwner();
const rigsRes = await fetch(`${base}/api/rigs`);
let rigs: Array<{ id?: string; name?: string }> = [];
const rigsRes = await fetchWithTimeout(fetch, `${base}/api/rigs`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live rig read timed out.",
consumeResponse: async res => {
if (res.ok) rigs = (await res.json()) as typeof rigs;
else await res.body?.cancel();
},
});
if (!rigsRes.ok) return null;
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
const rig = rigs.find((r) => r.name === rigName);
const matches = rigs.filter((r) => r.name === rigName);
if (matches.length > 1) throw new AmbiguousLiveRigError(matches.map(r => r.id ?? "(missing id)"));
if (matches.length === 0) return null;
const rig = matches[0];
if (!rig?.id) return null;
await ownerClient.assertLocalOwner();
const nodesRes = await fetch(`${base}/api/rigs/${rig.id}/nodes`);
let nodes: Array<{ logicalId?: string | null }> = [];
const nodesRes = await fetchWithTimeout(fetch, `${base}/api/rigs/${rig.id}/nodes`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live node read timed out.",
consumeResponse: async res => {
if (res.ok) nodes = (await res.json()) as typeof nodes;
else await res.body?.cancel();
},
});
if (!nodesRes.ok) return null;
const nodes = (await nodesRes.json()) as Array<{ logicalId?: string | null }>;
return nodes.map((n) => n.logicalId ?? "");
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
}
@@ -427,7 +628,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
exists: existsSync,
baseDir: import.meta.dirname,
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
exec: doctorExec,
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
@@ -441,7 +642,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
return await readLiveLogicalIds(base, rigName);
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
},
Loaded 100 of 1303 files, more files were not shown because too many files have changed in this diff. Show more