Compare commits

..
Author SHA1 Message Date
dev60-review aaa683a8e1 test(codex): a fresh Codex seat's managed guidance arrives as launch developer instructions
On a fresh launch of Codex 0.164.0 or later with no developer_instructions
configured, OpenRig's managed guidance should go in through
-c developer_instructions, leaving the tracked repository AGENTS.md and the
Codex home alone. A configured or unreadable layer, an older or unknown
version, and a resume keep the repository AGENTS.md. Roles and the
seat-skills line stay where they are today.
2026-11-03 06:13:26 +00:00
dev60-review 8f30607a64 fix(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An exact resume keeps a seat's saved startup selection, so the recorded
guidance file stayed at a newer launch's agent file while an older snapshot's
seat resumed on its repository file: inventory named the wrong file, and
another rig's down in the same cwd could take this live seat's guidance. The
restore now records the snapshot's choice, and nothing else in the selection
changes.

The link check that keeps a fresh launch off a linked agent file now also runs
where a recorded file is written: the exact-resume put-back and the Pi
adapter's guidance writes. A file that became a link after its launch gets
nothing written through it, nothing falls back to the repository file, and the
missing guidance is reported as a gap. The conversation still resumes.
2026-11-03 05:21:11 +00:00
dev60-review a5ff0fcda0 test(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An older snapshot restored exactly after a newer launch recorded the agent file
resumes on the repository file, so the record must name that file for
inventory and for another rig's down in the same cwd. A recorded agent file
that became a link after its launch gets nothing put back through it, and the
missing guidance is reported.
2026-11-03 05:18:58 +00:00
dev60-review bcdfd7a39c fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md
Pi loads its agent folder's AGENTS.md (PI_CODING_AGENT_DIR) before the
repository's, so a Pi seat's managed guidance now goes there and the person's
tracked repository AGENTS.md is no longer written at launch or restore.

A fresh launch asks the adapter once where its guidance goes and records the
answer with the startup context (node_startup_context.guidance_file, new and
nullable). Restore, rig down and node inventory read the recorded file, so one
target is used everywhere. A row or snapshot from before this records nothing
and keeps the repository file, so an existing seat isn't repointed by a restore.

The Pi adapter keeps the repository file when the seat's agent folder or its
AGENTS.md is a symlink or a hard link, since other sessions could read it there,
and says so in the launch warnings. OMP, Codex and Claude are unchanged.

rig down removes OpenRig's unedited blocks from the recorded file and from the
repository file, where an older launch of the same seat wrote them.
2026-11-03 05:02:06 +00:00
dev60-review e91e308b32 test(pi): a Pi seat's managed guidance goes to its agent folder and the tracked AGENTS.md is never written
A Pi seat loads its agent folder's AGENTS.md before the repository's, so its
managed guidance can live there instead of in the person's tracked file. These
cells pin that: the launch chooses and records the file, every reader uses the
recorded one, rig down and an exact resume leave the tracked AGENTS.md
byte-identical, a linked agent file or an older snapshot keeps the repository
file, and OMP is unchanged.
2026-11-03 04:54:27 +00: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
501 changed files with 31641 additions and 2144 deletions

No files matched your search

+621 -6
View File
@@ -8,6 +8,621 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.8]
**Not yet published.**
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.8
rig daemon stop && rig daemon start
```
Four migrations (142 to 145) store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and the person chosen to answer a
seat's permission requests.
([#2177](https://github.com/mvschwarz/openrig/pull/2177), [#2295](https://github.com/mvschwarz/openrig/pull/2295), [#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2301](https://github.com/mvschwarz/openrig/pull/2301))
- **The bundled openrig-core plugin advances from 0.1.23 to 0.1.24.** One line
of its seat-continuity skill changed. Managed refresh preserves replaced files
under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you
still need from that copy. ([#2287](https://github.com/mvschwarz/openrig/pull/2287))
### Permissions for runner seats
- **A runner seat's permission requests now reach a person, and the default is
permit.** The runner contract's first version refused every request. Section 7
of `docs/reference/harness-runner-contract.md` now covers who is asked:
- **With a default person,** "They get an ordinary ask in the Feed and on
Slack. No setup is needed."
- **To choose someone else,** run
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default.
- **When no one can be asked,** the request is "Permitted at once, with the
agent's own allow option."
- **Refusal is opt-in,** with `--deny`.
"A person's no is never a grant." An unanswered request "stays pending and
visible" until the agent's turn ends: "It's never timed out into a grant."
([#2301](https://github.com/mvschwarz/openrig/pull/2301))
- **A runner conformance check:**
`node docs/reference/runner-conformance/check-runner.mjs --runner '<runner command with {agent}>' --name <harness>`.
It covers the answered path, and needs Node and tmux but no daemon, model,
credentials or network.
([#2303](https://github.com/mvschwarz/openrig/pull/2303), [#2306](https://github.com/mvschwarz/openrig/pull/2306))
- **An example runner for DeepSeek Harness (`dsh`),** with its preset, is under
`docs/reference/community-harness-examples/dsh/`. OpenRig doesn't install it.
([#2305](https://github.com/mvschwarz/openrig/pull/2305))
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### Claude workers in OpenShell sandboxes
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
### Seats, delivery and the TUI
- **Seats:**
- an isolated tmux server per OpenRig home
(`rig config set terminal.tmux_server isolated`);
- `claude_mcp: none` for a Claude member;
- resuming with a new token file;
- first starts on `rig launch --seats`;
- an accurate account record and kept effort after `rig provider switch`;
- skill copies, activity hooks and startup declarations kept across
`rig down`, resumes and exports;
- clearer stops for handover's `fork:<seat>`, a missing conversation,
`claude` not found, Codex restart refusals and failed unpins.
([#1260](https://github.com/mvschwarz/openrig/pull/1260), [#2177](https://github.com/mvschwarz/openrig/pull/2177), [#2269](https://github.com/mvschwarz/openrig/pull/2269), [#2342](https://github.com/mvschwarz/openrig/pull/2342), [#2344](https://github.com/mvschwarz/openrig/pull/2344), [#2337](https://github.com/mvschwarz/openrig/pull/2337), [#2338](https://github.com/mvschwarz/openrig/pull/2338), [#2190](https://github.com/mvschwarz/openrig/pull/2190), [#2297](https://github.com/mvschwarz/openrig/pull/2297), [#2287](https://github.com/mvschwarz/openrig/pull/2287), [#2255](https://github.com/mvschwarz/openrig/pull/2255), [#2282](https://github.com/mvschwarz/openrig/pull/2282), [#2330](https://github.com/mvschwarz/openrig/pull/2330), [#2332](https://github.com/mvschwarz/openrig/pull/2332))
- **tmux:**
- with `OPENRIG_TMUX_CONTROL=1`, no crash on tmux 3.3a to 3.7c when the
control client ends while another leaves;
- a submit key that a read-only client blocks is pasted instead.
([#2317](https://github.com/mvschwarz/openrig/pull/2317), [#2325](https://github.com/mvschwarz/openrig/pull/2325), [#2345](https://github.com/mvschwarz/openrig/pull/2345))
- **Wakes:**
- `OPENRIG_WAKE_SUSPEND` matches destinations with a colon;
- prompt escalations stay deliverable without a reachable person;
- reminders stop at a full quota;
- `human@kernel` raises no false finding;
- OpenRig's own Codex hooks are trusted when no choice is recorded;
- refusals name their fix.
([#2353](https://github.com/mvschwarz/openrig/pull/2353), [#2343](https://github.com/mvschwarz/openrig/pull/2343), [#2281](https://github.com/mvschwarz/openrig/pull/2281), [#2286](https://github.com/mvschwarz/openrig/pull/2286), [#2294](https://github.com/mvschwarz/openrig/pull/2294), [#2290](https://github.com/mvschwarz/openrig/pull/2290), [#2284](https://github.com/mvschwarz/openrig/pull/2284), [#2280](https://github.com/mvschwarz/openrig/pull/2280), [#2273](https://github.com/mvschwarz/openrig/pull/2273), [#2202](https://github.com/mvschwarz/openrig/pull/2202))
- **Slack and usage:**
- typed replies to parked decisions are recorded as answers;
- row bodies render Markdown;
- resolved decisions stop showing as live asks;
- OpenRouter charge retries aren't delayed.
([#2293](https://github.com/mvschwarz/openrig/pull/2293), [#2299](https://github.com/mvschwarz/openrig/pull/2299), [#2316](https://github.com/mvschwarz/openrig/pull/2316), [#2349](https://github.com/mvschwarz/openrig/pull/2349), [#2314](https://github.com/mvschwarz/openrig/pull/2314))
- **Hosts and setup:**
- `rig host edit` and `rig host remove`;
- a warning when another OS user's daemon holds the port;
- `rig daemon status` shows a pending start.
([#2310](https://github.com/mvschwarz/openrig/pull/2310), [#2346](https://github.com/mvschwarz/openrig/pull/2346), [#2302](https://github.com/mvschwarz/openrig/pull/2302), [#2348](https://github.com/mvschwarz/openrig/pull/2348))
- **The TUI and views:**
- agent pages show health and recent work;
- pod, spec and instance-table views read correctly;
- `rig terminal open mission:<id> --follow --provider herdr` follows a
mission.
([#2263](https://github.com/mvschwarz/openrig/pull/2263), [#2264](https://github.com/mvschwarz/openrig/pull/2264), [#2265](https://github.com/mvschwarz/openrig/pull/2265), [#2266](https://github.com/mvschwarz/openrig/pull/2266), [#2327](https://github.com/mvschwarz/openrig/pull/2327), [#2260](https://github.com/mvschwarz/openrig/pull/2260), [#2289](https://github.com/mvschwarz/openrig/pull/2289))
- **Also:**
- MCP text is cut between whole characters;
- an ACP cancel survives a failed note;
- long transcript lines read faster;
- there are Pi and kernel dashboard recovery guides.
([#2268](https://github.com/mvschwarz/openrig/pull/2268), [#2262](https://github.com/mvschwarz/openrig/pull/2262), [#2274](https://github.com/mvschwarz/openrig/pull/2274), [#2186](https://github.com/mvschwarz/openrig/pull/2186), [#2275](https://github.com/mvschwarz/openrig/pull/2275))
### Behaviour that changed
- **A runner following section 7 gets answers or permits, not refusals.**
`--deny` keeps a seat refusing.
- **On tmux older than 3.7c,** `OPENRIG_TMUX_CONTROL=1` uses spawned clients
instead of a control client.
### Known limits
- **No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.
- **The answer owner is a registered person.** "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."
- **`allow_always` from a permit:** with no one to ask, "If the agent offers
only `allow_always`, the harness then remembers a permission nobody chose."
- **"Permits and answers aren't recorded beyond the runner's pane and the ask
itself."**
- **Coming as follow-ups:** recording a permit made when no one can be asked,
and passing a person's typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
- **The 0.7.7 and 0.7.6 limits below still apply.**
### Credits
Thanks to @Aschenbath, @dajiaohuang, @Vaishnavi220506, @Nikhila-Dhevi-R,
@Nikhi00718, @oodadoudou, @DeryFerd, @ch-davidriott, @vanducng, @Ethereal49,
@AdityaxDeore and @Mydudeclaps for merged contributions. Thanks to @salbang,
@ziadeh, @davey64, @bpamiri and @oneandonlydean for reports carried into this
release, and to @bpamiri for dsh-seat, whose fake agent the conformance check's
stand-in follows.
## [0.7.7]
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
fixes are also in 0.7.8.
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
idle prompt under Claude's "Update installed" line is recognized again, managed
compaction no longer types into a message you haven't sent, and a Claude seat
launched through a wrapper script can be rebuilt again.
### Before you upgrade
- **Restart the daemon after installing** so the fixes take effect:
```bash
npm install -g @openrig/cli@0.7.7
rig daemon stop && rig daemon start
```
There are no database migrations. The bundled openrig-core plugin stays at
0.1.23 with unchanged files.
### Fixed
- **A Claude seat under the "Update installed" line reads idle again.** After
Claude installs an update, it prints "✔ Update installed · /restart to apply"
(or "Restart to apply") below its mode bar. OpenRig didn't recognize that line,
so a seat at an empty prompt read unknown instead of idle, and managed
compaction waited. Both spellings are now recognized; lines that only look
similar still read unknown. ([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- **Managed compaction no longer types into a draft.** With the optional typing
guard off (the default), compaction's preparation prompt could be pasted onto
a message someone had typed but not sent. Now the preparation prompt,
`/compact` and the restore prompts check the input box first: typed or
unreadable input holds the step with a reason. A held restore prompt goes ahead
on its own once the box is empty; a manual `rig compact` says why it stopped,
to be retried once the box is empty. An empty box or Claude's own suggestion
goes ahead.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- **A Claude seat launched through a wrapper script can be rebuilt again.** On
0.7.6, `rig seat handover --source rebuild` on a seat whose `claude` is a `#!`
script that starts the real Claude without `exec` stopped after 30 seconds with
"Waiting to confirm the new Claude process in this pane", though the new Claude
was running. The readiness check now also accepts the script's own direct child
when it names this launch's conversation; every other check is unchanged.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
- **A failed handover no longer moves the seat's resume target** to the new,
unprimed conversation while the seat keeps its previous occupant.
([#2354](https://github.com/mvschwarz/openrig/pull/2354))
### Known limits
- **Other reasons managed compaction can wait are unchanged.**
- **The draft check reads the input box just before pasting.** Typing that starts
in the same moment can still be appended to.
- **A Claude seat whose current occupant generation was never registered,** such as
an adopted Claude carrying another generation, or a managed launch whose tenure
record failed to write, keeps its first conversation token, but later hooks don't
move it.
- **The 0.7.6 known limits below still apply.**
### Thanks
- @oneandonlydean for the report and the evidence in #2341.
## [0.7.6]
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
open work through handoffs and completions. It also keeps more seat settings
through expansion and restore, reports retained dead panes honestly, and repairs
queue and Slack delivery paths.
### Before you upgrade
- **Restart the daemon after installing** so the new code and database updates
take effect:
```bash
npm install -g @openrig/cli@0.7.6
rig daemon stop && rig daemon start
```
Two migrations add the selected account-home path to nodes and the runner
declaration to sessions. Both have prefix 141; their distinct full filenames
identify them. ([#2143](https://github.com/mvschwarz/openrig/pull/2143), [#2239](https://github.com/mvschwarz/openrig/pull/2239))
- **The bundled openrig-core plugin advances from 0.1.22 to 0.1.23.** Claude's
activity hook clears permission marks after a tool batch. Managed refresh
preserves replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`;
reapply local edits you still need from that copy. ([#2209](https://github.com/mvschwarz/openrig/pull/2209))
### Follow the work in a slice
- **Open `rig terminal open slice:<name-or-id> --follow --provider herdr`.**
Membership comes from pending, in-progress and blocked queue rows tagged with
that exact slice. Owners appear once with all their obligations listed;
unavailable owners and tile overflow stay named.
Handoffs retain the association unless their tags are replaced. New slice
views default to four automatic tiles; `--max-tiles` changes that. Known empty
slices stay following; incomplete reads retain the previous viewers. Pins,
drafts, unknown input and focus retain departed viewers, and closing a viewer
never stops its agent. Slice following targets the Linux Herdr 0.9.5 /
protocol 22 path. Mission following is not included.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **Named Herdr sessions use their configured endpoint throughout viewer and
follow operations.** The TUI's terminal action opens the agent, pod or rig
currently in view. ([#2205](https://github.com/mvschwarz/openrig/pull/2205), [#2232](https://github.com/mvschwarz/openrig/pull/2232))
- **Client-visible HTTP change:** `/api/terminal/viewer/ensure` now returns 400
for `view_required` and `unknown_provider`, where it returned 200 with an
error body. Clients should continue reading the body code. Missing slice
identities return 404; ambiguous or unavailable identities return 409.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **When a slice's folder name repeats across missions, tag its work with the
slice's unique ID** (`rig queue create --slice <id>`) or add
`--mission <mission>`. A row tagged only with the shared name cannot be placed,
so the view reads incomplete until it is.
([#2261](https://github.com/mvschwarz/openrig/pull/2261))
- **A join refused before a pane split can join on a later refresh.** Hold
notices keep the seat and phase. The first notice keeps the original reason;
later notices can show a generic reason, while the original failure remains
in the journal. A created viewer whose attach has not yet run can continue
without replacing its pane.
([#2271](https://github.com/mvschwarz/openrig/pull/2271))
### Seats, accounts and harnesses
- **Expansion keeps the member settings accepted by `rig up`,** including env,
effort and startup settings. Restores retain the model saved with
`rig seat set-model`. ([#2250](https://github.com/mvschwarz/openrig/pull/2250), [#2227](https://github.com/mvschwarz/openrig/pull/2227), [#2228](https://github.com/mvschwarz/openrig/pull/2228))
- **Move a running Codex or Claude Code seat to an existing account home** with
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`;
preview with `--dry-run`. This starts a fresh conversation with a handover,
retaining the seat's address and work. `--clear-account-home` returns to the
configured defaults. ([#2143](https://github.com/mvschwarz/openrig/pull/2143))
- **Claude subscription/API-key switching has a same-conversation path:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. In-flight work is
interrupted. The API-key path remains unqualified; verify the resulting
account and conversation. A new Claude member can instead name a prepared
subscription token in its spec with `claude_token: {label: name, file: /abs/token}`.
([#2191](https://github.com/mvschwarz/openrig/pull/2191), [#2246](https://github.com/mvschwarz/openrig/pull/2246))
- **Relaunch retains the selected seat and its observed executable.** Codex
identity reads preserve OS argument boundaries, and in-place restart can
recognize a verified Node launcher named `MainThread`. This does not establish
that the reported #2200 restart refusal is fixed. ([#2196](https://github.com/mvschwarz/openrig/pull/2196), [#2212](https://github.com/mvschwarz/openrig/pull/2212), [#2247](https://github.com/mvschwarz/openrig/pull/2247))
- **Managed seats inherit the daemon's SSH agent socket** for git over SSH;
check the daemon's agent with `ssh-add -l`. ([#2222](https://github.com/mvschwarz/openrig/pull/2222))
- **Community presets can report turns and accept a declared model.**
`runner: true` with `launch_result: true` records runner activity per launch.
`passes_model: true` passes the requested model as `OPENRIG_MODEL`; it does not
observe the effective model. The Cursor and Hermes examples leave this off
pending native qualification. These are opt-in preset fields, not a completed
integration with every harness. ([#2239](https://github.com/mvschwarz/openrig/pull/2239), [#2243](https://github.com/mvschwarz/openrig/pull/2243))
- **Draft integration author contracts** describe harness, runner and platform
duties, distinguishing implemented interfaces from proposals. Start at
[the author-contract entry point](docs/reference/author-contracts.md).
([#2272](https://github.com/mvschwarz/openrig/pull/2272))
### Delivery and Slack
- **Original queue wakes retry after a selection prompt clears.** Retries identify
OpenRig as the sender, workflow entry keeps its caller, and prompt-blocked
alerts remain routable when the original sender is on another host.
([#2235](https://github.com/mvschwarz/openrig/pull/2235), [#2229](https://github.com/mvschwarz/openrig/pull/2229), [#2237](https://github.com/mvschwarz/openrig/pull/2237))
- **Work sent to a terminal-only seat gets a recovery finding for a person.**
Cross-host `rig queue create --verify` reads the destination's receipt;
unreadable receipts remain unconfirmed without replaying the create.
([#2192](https://github.com/mvschwarz/openrig/pull/2192), [#2193](https://github.com/mvschwarz/openrig/pull/2193))
- **Slack button answers resume decisions parked on the person asked.** Replies
Slack places outside a deleted thread are recorded at their actual location,
so the next reply reaches the right conversation. ([#2245](https://github.com/mvschwarz/openrig/pull/2245), [#2240](https://github.com/mvschwarz/openrig/pull/2240))
- **Use `rig slack setup --reactions off` to stop acknowledgement emoji waking
agents.** The default remains `route`, and regenerating the app manifest
preserves the choice. Alert selection filters eligible notifications without
dropping older eligible asks behind an arbitrary row cap. ([#2241](https://github.com/mvschwarz/openrig/pull/2241), [#2183](https://github.com/mvschwarz/openrig/pull/2183))
### Status, inventory and help
- **A readiness deadline no longer ends a still-starting daemon.** The CLI
returns a nonzero pending result naming the child PID, launch endpoint and
log. It leaves the child running and retains the startup reservation, without
declaring readiness or allowing a duplicate start. Follow that pending
guidance before recovery. ([#2267](https://github.com/mvschwarz/openrig/pull/2267))
- **A verified exited process in a retained pane reads `pane_exited`,** instead
of running and ready. Inventory avoids discarded graph loads, scopes startup
history to the requested rig's nodes, and reuses node identity for activity
reads. These changes make no universal latency claim. ([#2248](https://github.com/mvschwarz/openrig/pull/2248), [#2225](https://github.com/mvschwarz/openrig/pull/2225),
[#2226](https://github.com/mvschwarz/openrig/pull/2226), [#2236](https://github.com/mvschwarz/openrig/pull/2236))
- **Completed mission labels reflect accepted proof**, without making proof
readiness complete a mission. Snapshot ages use UTC correctly; emoji stay
whole when TUI health, config and rig-table cells are shortened. ([#2249](https://github.com/mvschwarz/openrig/pull/2249),
[#2251](https://github.com/mvschwarz/openrig/pull/2251), [#2252](https://github.com/mvschwarz/openrig/pull/2252), [#2253](https://github.com/mvschwarz/openrig/pull/2253))
- **Smaller command fixes:** chatroom tmux rig names stay literal; a trailing
slash in `OPENRIG_URL` still identifies the same daemon; a trailing slash in
the missions root no longer hides a slice's mission. `rig host doctor` names
the entry replies actually reach. ([#2218](https://github.com/mvschwarz/openrig/pull/2218), [#2220](https://github.com/mvschwarz/openrig/pull/2220), [#2221](https://github.com/mvschwarz/openrig/pull/2221), [#2214](https://github.com/mvschwarz/openrig/pull/2214))
- **Help and contributor checks:** edge help explains deferred escalation
targets; section addressing ignores HTML comments; the guide covers shared
kernel-dashboard recovery. Install pins, help links and earlier release text
are corrected. Scenario fixtures honor send verification, execute tmux
directly, and reject unknown step verbs at schema load. ([#2204](https://github.com/mvschwarz/openrig/pull/2204), [#2211](https://github.com/mvschwarz/openrig/pull/2211),
[#2213](https://github.com/mvschwarz/openrig/pull/2213), [#2238](https://github.com/mvschwarz/openrig/pull/2238), [#2244](https://github.com/mvschwarz/openrig/pull/2244), [#2254](https://github.com/mvschwarz/openrig/pull/2254), [#2256](https://github.com/mvschwarz/openrig/pull/2256), [#2257](https://github.com/mvschwarz/openrig/pull/2257), [#2215](https://github.com/mvschwarz/openrig/pull/2215), [#2216](https://github.com/mvschwarz/openrig/pull/2216),
[#2217](https://github.com/mvschwarz/openrig/pull/2217))
### Known limits
- **The Codex hook-refresh refusal in [#2200](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved.** If `rig seat restart <seat> --keep-conversation` refuses,
nothing is stopped: leave the seat running and report the refusal with CLI
and daemon versions. A refused restart has not refreshed the hooks.
- **A viewer whose attach already ran and then exited back to a shell is not
restarted automatically.** The whole slice view can hold until that tile is
resolved. No tested workaround is offered for that case.
- **Following protects uncertain input by retaining tiles.** A larger workspace
or explicit `--max-tiles` can help with overflow. Protected Codex delivery can
still retain messages it cannot confirm before paste; retained is not
delivered. Disabling its typing guard removes protection and is not recovery.
- **Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk.** Slice following does not require them.
### Credits
Thanks to @seanmcn, @DeryFerd, @Aschenbath, @Vaishnavi220506, @bradvin,
@jay23606, @rudycelekli and @Nikhi00718 for merged contributions, and to
@akushonkamen, @jcmurrayii, @KM199 and @pdurlej for contributed changes, reports
and reproductions carried into this release.
## [0.7.5]
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
workspace as a task's current owners join and leave. Joining viewers do not
request focus; departures remove viewers, not the agents or conversations.
Follow mode is scoped to Linux Herdr 0.9.5 / protocol 22. The exercised journey
used shell stand-ins; real-agent follow qualification and active render cost
are not established by that result. Changes since 0.7.4:
### Before you upgrade
- **Restart the daemon after installing** to run the new code and database
update. Migration 126 adds deferred `escalates_to` target addresses to the
edges table, preserving existing edges. Previously inert escalation edges
now affect recovery for seats without a delegation parent.
```bash
npm install -g @openrig/cli@0.7.5
rig daemon stop && rig daemon start
```
- **The bundled openrig-core plugin goes from 0.1.21 to 0.1.22.** Its
`queue-handoff` skill teaches agents to park waits on CI or a long job with
a continuation and wake. As before, managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply any local
edits you still need from that copy. No setting is added or removed.
- **A Codex hook-refresh refusal remains unresolved.** The earlier remedy
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
(preview with `--plan`) was reported to refuse one Codex seat upgraded from
0.6.x. Its cause is unknown: the inspected 0.7.4 check already accepts the
reported process tree. [#2200](https://github.com/mvschwarz/openrig/issues/2200)
is not fixed in 0.7.5; this is not an established refusal of all staged-script
launches. A refused restart does not complete the hook refresh. If it
refuses, nothing is stopped: leave the seat running and report the refusal
with CLI and daemon versions. If Codex asks for hook review, review the
hooks you intend to enable.
### Follow one task in one workspace
- **Follow the current owners of a queue item:**
`rig terminal open qitem:<id> --follow --provider herdr`. The view derives
membership from the task's open rows and explicitly linked successors.
Joining seats get verified viewer tiles in the same workspace. A complete
empty task drains removable tiles and keeps the workspace; after retained
tiles are gone, following stops. Run `--follow` again to resume it.
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Keep control of the view:** `--pin`, `--unpin`, `--suppress`,
`--unsuppress`, `--suppression-ms` and `--max-tiles` adjust presentation.
`--no-follow` leaves the layout in place. The default is eight tiles,
four seconds of departure grace and twelve more seconds for a focused
leaver. Pins can exceed the cap; input-retained leavers do not consume a
joining seat's slot. See [terminal sessions](docs/reference/terminal-sessions.md).
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Uncertain input keeps its tile.** Typed drafts and unreadable composers
retain departed viewers, including Pi, terminal and community harness
seats whose input cannot be confirmed. Unpinning does not override that.
There is one delayed input recheck, then queue/session events or an explicit
follow command can reconsider it. Failed or incomplete reads retain the
previous view with an explanation. Activity hooks do not trigger renders.
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Protected Codex delivery no longer strands a message it can't submit.** On a
Codex seat whose typing guard is draft-aware or openrig-urgent, a message
whose complete text can't be confirmed visible before paste is retained, not
sent, instead of being pasted where it can't be submitted. The default (guard
off) is unchanged. Use a larger pane or a shorter message; turning the typing
guard off turns draft protection off and is not a recovery. Pastes over 1,000
code points are retained. Wrapped lines use a conservative bound, so most
wrapped wakes in common four-tile views can be retained even when they fit
natively; approximate standard-envelope examples are one-paragraph bodies over
about 115 characters at 80×22, 192 at 80×24, or 526 at 120×30 (examples, not
limits). A later resize is not atomic, and layouts with Codex's image-enabled
ambient pet are unqualified.
([#2242](https://github.com/mvschwarz/openrig/pull/2242))
- **Codex 0.164's two-row composer footer is recognized.** A departed Codex
agent's empty composer no longer reads as unconfirmed input that keeps its
tile. Delivery's draft-aware and openrig-urgent typing guards use the same
reader, so an empty Codex 0.164 composer there can now receive typed
delivery. ([#2206](https://github.com/mvschwarz/openrig/pull/2206))
- **Clients can ensure a missing viewer without relaying out existing panes**
through `POST /api/terminal/viewer/ensure`. It rechecks task membership and
existing viewer identity before creating or reusing a tile. Preview names
`not-provisioned`, `verified` or `unconfirmed`; membership gives no input
authority. This path has the same Linux Herdr 0.9.5 / protocol 22 scope.
([#2133](https://github.com/mvschwarz/openrig/pull/2133))
- **An unconfirmed pane no longer counts as opened.** CLI and TUI output name
it as degraded with `pane_unconfirmed`, distinguish it from a skipped seat,
and omit its restart-command report. An all-unconfirmed open exits nonzero.
Inspect before retrying; this is not proof that nothing opened.
([#2173](https://github.com/mvschwarz/openrig/pull/2173))
### Fixes by what you see
- **Slow successful sends have time to answer.** Local and HTTP `rig send`
wait 60 seconds, plus an explicit idle wait, instead of five seconds locally
or thirty over HTTP. A timeout still means unconfirmed. The advice now says
that a busy recipient may hold input off-screen: missing pane text does not
prove non-delivery, so reconcile before another send or duplicate queue item.
No automatic resend is added. This does not diagnose the daemon's latency.
([#2198](https://github.com/mvschwarz/openrig/pull/2198))
- **A wake that was unroutable before its seat had a pane is retried once the
binding exists,** through the row's existing wake intent. The outbox does
not send that same intent again after it is settled as delivered.
([#2170](https://github.com/mvschwarz/openrig/pull/2170))
- **A parentless seat's recovery can follow its declared `escalates_to`
address,** including another rig on the same daemon. A delegation parent
still takes precedence. Unavailable or ambiguous targets use the explained
fallback; an already-open recovery keeps its recorded owner. This is one
escalation hop, not cross-host routing.
([#1772](https://github.com/mvschwarz/openrig/pull/1772))
- **A stuck finding needs evidence to close.** Delivery, pickup or explicit
custody can resolve it; a changed reporting threshold, an unreadable
registry or a failed wake becoming unconfirmed cannot silently clear it.
([#2153](https://github.com/mvschwarz/openrig/pull/2153))
- **Fresh Claude handovers wait for the current launch.** Retained scrollback
or an echoed shell command cannot make a new process look ready. The context
packet waits for the matching process and Claude's complete drawn input frame.
([#2159](https://github.com/mvschwarz/openrig/pull/2159))
- **Runtime send refusals return HTTP 409 rather than 500.** A stale recorded
Claude pane names `rig reconcile-session <seat>` as the repair; nothing is
typed on that refusal. ([#2157](https://github.com/mvschwarz/openrig/pull/2157))
- **`rig down` keeps a rig down after a rolled-back start.** It records the
explicit stop without rewriting the superseded session's history. The
`rig start --last` help now explains which rigs return and how an explicit
stop keeps them out; the kernel is not selected by `--last`.
([#2166](https://github.com/mvschwarz/openrig/pull/2166))
- **Status and configuration describe the selected rig.** `rig ps` reports
`periodicSnapshotActive` only for rigs the scheduler actually snapshots
([#2168](https://github.com/mvschwarz/openrig/pull/2168)); the web Overview
reads the rig's current exported configuration instead of a same-named
library template ([#2188](https://github.com/mvschwarz/openrig/pull/2188)).
- **Host labels and next steps are clearer.** The TUI shows the configured
local host name while retaining its stable identity
([#2162](https://github.com/mvschwarz/openrig/pull/2162)). `rig host add` and
`pair` explain how to opt an HTTP host into remote Feed attention; registration
leaves that setting unchanged ([#2179](https://github.com/mvschwarz/openrig/pull/2179)).
- **Small CLI corrections:** `attach --self` reads tmux's rewritten separators
([#2178](https://github.com/mvschwarz/openrig/pull/2178)); transcript and
capture line counts use their normalized values on local and remote paths
([#2164](https://github.com/mvschwarz/openrig/pull/2164)); stdio MCP warns on
out-of-range daemon ports while continuing to serve, without substituting a
port ([#2160](https://github.com/mvschwarz/openrig/pull/2160)).
- **Notification adapters release unread HTTP response bodies,** without
changing their send result or waiting for cleanup. No performance improvement
is claimed here. ([#2172](https://github.com/mvschwarz/openrig/pull/2172))
- **Scope audits agree on slice folders numbered 100 and above.** The guide
also explains that moving a slice reopens its proof judgments; a clean
structural audit is not a proof judgment.
([#2194](https://github.com/mvschwarz/openrig/pull/2194))
### Presets, hosted members and guides
- **The `pi-tui` example runs Pi's own terminal UI as a community harness.**
It gives each seat its own Pi agent folder and passes startup input to Pi.
One Pi 1.4.0 / OpenRig 0.7.4 run showed a parked wake returning to the same
session and an unattended handoff to a second seat. It did not prove Pi
chooses that coordination by itself. Resume is not declared. Use it
unattended: a wake can submit a person's half-typed draft, and unrecognized
dialogs can receive the send. Busy-turn wakes and other Pi versions remain
unqualified. ([#2182](https://github.com/mvschwarz/openrig/pull/2182),
[#2184](https://github.com/mvschwarz/openrig/pull/2184))
- **Interactive Pi setup is explicit:** copy the coordination skills and use
`delivery_hint: send_text` for a startup file that must reach a preset's
first input. A plain terminal seat does not receive queue wakes. Setting its
typing guard to `off` can type notices into a shell; keep the default for a
seat that can return to one. These are documented limits, not new guards.
([#2174](https://github.com/mvschwarz/openrig/pull/2174),
[#2180](https://github.com/mvschwarz/openrig/pull/2180),
[#2181](https://github.com/mvschwarz/openrig/pull/2181),
[#2185](https://github.com/mvschwarz/openrig/pull/2185))
- **Hosted-member guidance names its real reach.** A member without a wake
trigger checks work when its owner next starts a session; a listed tool is
not a grant, and completed work closes with `no-follow-on`. Hosted members
exposed over the internet remain **experimental, unsupported, off by default
and at your own risk**. The guide explains narrow grants and token revocation;
this terminal-follow release requires no internet exposure.
([#2187](https://github.com/mvschwarz/openrig/pull/2187),
[#2189](https://github.com/mvschwarz/openrig/pull/2189))
- **Publishing a team bundle:** add and declare a README after `rig export`,
then run `rig bundle check` at the revision being submitted
([#2176](https://github.com/mvschwarz/openrig/pull/2176)). Queue guidance
teaches parking long waits with a continuation and wake
([#2167](https://github.com/mvschwarz/openrig/pull/2167)).
- **Contributor maintenance:** the test guide corrects installed-scenario
counts and cases ([#2165](https://github.com/mvschwarz/openrig/pull/2165));
the compaction receipt fixture shares one clock, with no product change
([#2169](https://github.com/mvschwarz/openrig/pull/2169)). Published release
notes, the capability map and roadmap records were carried forward
([#2161](https://github.com/mvschwarz/openrig/pull/2161),
[#2175](https://github.com/mvschwarz/openrig/pull/2175),
[#2197](https://github.com/mvschwarz/openrig/pull/2197)).
### Limits carried forward
- **Follow mode is Linux Herdr 0.9.5 / protocol 22 only.** Other platforms,
providers and selector kinds retain one-shot open. Its exercised journey
used shell stand-ins, not native agents. Active render cost is unmeasured.
The first workspace on an empty provider may gain focus, and a viewer can
resize the agent's terminal; `ignore-size` is not resize isolation.
- **Unreadable composers retain departed tiles.** This includes input the
inspector cannot read on Pi, terminal or community harness seats; unpinning
alone does not establish that closing is safe.
- **Escalation across rigs is same-daemon only.** Upgrade followed by snapshot
restore of deferred escalation targets has not been qualified.
- **A webhook 2xx is acceptance of the ring, not pickup.** A hosted member's
activity remains unknown between calls. Principals record attribution, not
authentication. See [hosted members](docs/reference/hosted-members.md).
- **Reviewed-history resume remains a client operation through startup review,**
not a CLI command. Kernel restore evidence lasts one daemon process.
- **OpenClaw interrupted local turns are not recovered; Hermes has no declared
resume or measured idle gateway exit.** `rig down` stops a community harness's pane; survival of independently spawned
children remains untested. Inspect actual processes before relying on their exit.
- **The reported Codex restart refusal in #2200 remains unresolved,** as do
the other 0.7.4 limits unless a change above names them fixed: Codex permission qualification
only on 0.164.0, earlier removed hook approvals, SSH tiles after a Herdr
restart, unqualified native cmux, Slack-only shared channels and the existing
browser-sizing and old Slack-thread limits. Isolated account-home switching
is not included.
### Credits
Thanks to @icey-zzz (#1772), @Aschenbath (#2153, #2159, #2162, #2169, #2179),
@DeryFerd (#2164, #2165), @Nikhi00718 (#2172) and @dajiaohuang (#2188).
Thanks also to @rudycelekli for the contribution from #1416 completed in #2160;
to @AdamWhitehurst (#1544, #2049), @kevinhsykes (#2151), @aanestesia (#605),
@todie (#1419) and @tgrundtvig (#21), whose reports led to fixes here; and to
the other people whose reports and reviews led to these fixes.
## [0.7.4]
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
@@ -667,10 +1282,11 @@ from the commit tagged `v0.7.3`. The changes since 0.7.2:
current TUI sends it (a request without a seat identity); an older TUI's
answer on someone else's ask is refused.
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
- **A Codex seat's permission settings are passed as launch flags; that Codex
enforces them was not proven natively.** Going back to an older OpenRig keeps
them unread (see Before you upgrade). The Codex trust fixes stop new duplicate
hook groups but don't remove existing ones.
- **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. Other Codex
versions weren't tested. Going back to an older OpenRig keeps them unread (see
Before you upgrade). The Codex trust fixes stop new duplicate hook groups but
don't remove existing ones.
([#2048](https://github.com/mvschwarz/openrig/pull/2048),
[#2035](https://github.com/mvschwarz/openrig/pull/2035))
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
@@ -735,8 +1351,7 @@ reported [#1653](https://github.com/mvschwarz/openrig/issues/1653) (worked on in
reported [#980](https://github.com/mvschwarz/openrig/issues/980) (addressed by
[#2081](https://github.com/mvschwarz/openrig/pull/2081)); @rakoort, whose
suggestion in [#1679](https://github.com/mvschwarz/openrig/issues/1679) became
[#2086](https://github.com/mvschwarz/openrig/pull/2086) (the issue's main
report is still open); @george661, whose
[#2086](https://github.com/mvschwarz/openrig/pull/2086); @george661, whose
[#1710](https://github.com/mvschwarz/openrig/issues/1710) led to
[#2093](https://github.com/mvschwarz/openrig/pull/2093).
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.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,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.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.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.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.
+2 -4
View File
@@ -6,11 +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).
## Released in 0.7.4
## Released in 0.7.6
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
[0.7.6 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6): follow a slice's work in one terminal workspace, plus steadier daemon start and delivery. 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) ---
@@ -398,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.
@@ -293,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
+27 -5
View File
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
**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
@@ -350,6 +350,8 @@ 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` |
@@ -485,7 +487,7 @@ do not establish connectivity to a live peer.
| 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` |
@@ -541,6 +543,7 @@ do not establish connectivity to a live peer.
| `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.
@@ -697,6 +700,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 |
@@ -794,8 +815,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>`<br>`--token-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--json` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<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` |
@@ -984,9 +1005,10 @@ for input and receipt examples, rollback, and snapshot behavior.
| `rig seat set-terminal-policy <seat>` | — | `--preset <preset>` **required**<br>`--helper <addresses...>`<br>`--rig-default`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json`<br>`--peer-compaction <decision>`<br>`--peer-model <decision>`<br>`--self-compaction <decision>`<br>`--self-model <decision>`<br>`--openrig-compaction <decision>`<br>`--openrig-model <decision>` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat 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` |
+22
View File
@@ -168,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
+30
View File
@@ -562,6 +562,10 @@ remain shared.
- 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
@@ -737,6 +741,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.
+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 not qualified | OpenRig asks a person for a runner's permission requests and returns the answer. No end-to-end dsh integration is established, and the native verdict is pending. 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.
@@ -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, and checked against a stand-in agent only
- 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 until a real dsh has reopened a conversation and recalled it; 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); });
}
@@ -10,3 +10,4 @@ limits:
- 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.
+103 -4
View File
@@ -4,6 +4,9 @@ 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;
@@ -15,14 +18,14 @@ it never reads an unsupported capability as idle, working, ready or verified.
| 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. |
| 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. |
| 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.
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
@@ -48,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 `$(…)`
@@ -144,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
@@ -241,8 +285,32 @@ on it, not Pi choosing by itself to park or hand work on.
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
@@ -255,6 +323,37 @@ OpenRig records `node.startup_first_input_passed` with the path and `submission:
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
@@ -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.
+45 -5
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
@@ -191,11 +192,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.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
@@ -509,7 +510,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
@@ -848,13 +854,47 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| "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 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. `rig setup` (its tmux
+439
View File
@@ -0,0 +1,439 @@
# 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. A dsh port implementing this recipe is not qualified.
> - **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).
>
> No harness has been run end to end under this contract yet, and the native verdict is pending. The first candidate
> is dsh over ACP.
>
> 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. 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 |
| 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.
**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. 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 in 0.7.8:**
- **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.
- **Permits and answers aren't recorded beyond the runner's pane and the ask itself.** A permit with no one to ask
opens no ask.
## 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?
+32
View File
@@ -107,6 +107,38 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
`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` |
- **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.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
@@ -0,0 +1,83 @@
# 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 `examples/extensions/tools.ts`. 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
+3 -2
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.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.
@@ -105,7 +105,8 @@ The daemon starts and boots the kernel with the coding agents you're signed in t
**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,
+206 -10
View File
@@ -526,6 +526,105 @@ 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 or Claude Code seat can change its native 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.
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.
@@ -534,6 +633,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
@@ -597,12 +742,14 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `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). |
| `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. |
@@ -616,6 +763,53 @@ 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 Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -805,19 +999,21 @@ the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
`rig export` includes the rig's saved culture file and startup block, and now also
writes each pod's saved startup files under that pod. The file order, delivery
hints, required flags, orientation and `applies_on` selections are retained.
Previously the saved pod file lists were omitted. Pods without a recorded file
list, including older rigs, keep their existing export behavior; the exporter
does not reconstruct declarations from a member's combined startup context.
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 or pod file;
missing optional files do not trigger that warning. Pod startup actions and
member startup blocks remain outside this export; keep the source spec when you
need those declarations.
original `--rig-root`. The CLI warns once per missing required rig, pod or member
file; missing optional files do not trigger that warning.
### Files
@@ -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.
+816
View File
@@ -0,0 +1,816 @@
#!/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(2500), ignoreCancel: true }, {}] } });
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))}`);
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, scale(3000)), "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);
+144
View File
@@ -0,0 +1,144 @@
#!/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
// 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) return; clearTimeout(timer); cancelTurn = null; resolve(true); };
});
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"); });
+9 -1
View File
@@ -109,6 +109,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
@@ -199,7 +207,7 @@ rig slack channel-map remove pr@my-rig
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel only.
- 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
+163 -1
View File
@@ -184,7 +184,7 @@ GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
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
@@ -306,3 +306,165 @@ when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
## 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.
+166
View File
@@ -0,0 +1,166 @@
# 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.
## 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.
+1 -85
View File
@@ -1136,7 +1136,6 @@
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
@@ -2902,7 +2901,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -3125,7 +3123,6 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -3150,7 +3147,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3164,7 +3160,6 @@
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -3226,7 +3221,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3246,7 +3240,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3260,7 +3253,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3405,7 +3397,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3419,7 +3410,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3436,7 +3426,6 @@
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3452,7 +3441,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -3462,7 +3450,6 @@
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -3480,7 +3467,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -3664,7 +3650,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3699,7 +3684,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3745,7 +3729,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3760,7 +3743,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
@@ -3773,7 +3755,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3796,7 +3777,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3806,7 +3786,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3823,7 +3802,6 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3887,7 +3865,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/estree-walker": {
@@ -3904,7 +3881,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3914,7 +3890,6 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
@@ -3927,7 +3902,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -3947,7 +3921,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -3991,7 +3964,6 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
@@ -4011,7 +3983,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4113,7 +4084,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -4135,7 +4105,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4158,7 +4127,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4213,7 +4181,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4247,7 +4214,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4286,7 +4252,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4308,7 +4273,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4355,7 +4319,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
@@ -4427,14 +4390,12 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4444,7 +4405,6 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -4518,7 +4478,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isbot": {
@@ -4534,7 +4493,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
@@ -4550,7 +4508,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -4626,7 +4583,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/json5": {
@@ -4723,7 +4679,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4733,7 +4688,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4747,7 +4701,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4794,7 +4747,6 @@
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4804,7 +4756,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -4852,7 +4803,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -4888,7 +4838,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
@@ -4905,7 +4854,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4968,7 +4916,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4981,7 +4928,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
@@ -4998,7 +4944,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -5021,7 +4966,6 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -5031,7 +4975,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5047,7 +4990,6 @@
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -5111,7 +5053,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
@@ -5293,7 +5234,6 @@
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -5321,7 +5261,6 @@
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -5358,7 +5297,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -5372,7 +5310,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -5388,7 +5325,6 @@
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5654,7 +5590,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -5701,7 +5636,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -5737,7 +5671,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
@@ -5785,7 +5718,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -5805,14 +5737,12 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -5825,7 +5755,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5835,7 +5764,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5855,7 +5783,6 @@
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5872,7 +5799,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5891,7 +5817,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5946,7 +5871,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6246,7 +6170,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6"
@@ -6314,7 +6237,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
@@ -6333,7 +6255,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6377,7 +6298,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6475,7 +6395,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -7201,7 +7120,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7234,7 +7152,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
@@ -7310,7 +7227,6 @@
"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==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
@@ -7353,6 +7269,7 @@
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
@@ -7373,7 +7290,6 @@
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+1 -1
View File
@@ -48,6 +48,7 @@
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
@@ -64,7 +65,6 @@
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+5 -1
View File
@@ -179,7 +179,11 @@ 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) => {
+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);
}
}
+23
View File
@@ -188,6 +188,29 @@ export class DaemonClient {
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
+8 -3
View File
@@ -11,7 +11,7 @@ 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.
@@ -20,7 +20,7 @@ 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" };
@@ -31,7 +31,11 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
// 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.
@@ -120,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;
+2 -1
View File
@@ -227,7 +227,8 @@ function renderBroadcastResults(data: Record<string, unknown>): void {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} 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}`);
}
+7 -4
View File
@@ -52,10 +52,13 @@ 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
map that never reaches idle reports map_complete_seat_never_idle; an unfinished
map reports preparation_incomplete. Manual requests still share one 180-second budget.
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(),
+7 -3
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", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -96,8 +96,10 @@ 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
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
@@ -106,6 +108,7 @@ Keys:
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
@@ -193,6 +196,7 @@ Precedence: CLI flag > environment variable > config file > default`)
// 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).`);
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);
+7 -1
View File
@@ -345,13 +345,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(),
+77 -6
View File
@@ -18,6 +18,7 @@ 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;
@@ -40,7 +41,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 the port's /healthz body may take to arrive (default 2000 ms); after that its owner is unknown. */
ownerReportTimeoutMs?: number;
/**
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
*
@@ -57,6 +63,71 @@ export interface DoctorDeps {
const DEFAULT_PORT = 7433;
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();
@@ -234,13 +305,13 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: 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 fetchFn(`${daemonBase}/healthz`, { signal: owner.signal });
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",
+6
View File
@@ -88,6 +88,8 @@ interface TeardownResult {
keptSkills?: string[];
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
keptGuidance?: string[];
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
keptFiles?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
@@ -295,6 +297,10 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
for (const block of result.keptGuidance) console.log(` ${block}`);
}
if (result.keptFiles && result.keptFiles.length > 0) {
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
for (const file of result.keptFiles) console.log(` ${file}`);
}
if (result.leftRunning && result.leftRunning.length > 0) {
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
+1 -1
View File
@@ -8,7 +8,7 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.description("Inspect and control rig services; for execution placement use rig environment")
.addHelpText("after", `
Examples:
rig env status secrets-manager
+172
View File
@@ -0,0 +1,172 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { Command } from "commander";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { DaemonClient } from "../client.js";
import { getDaemonPath, getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { addGrant, grantFor, mcpTokenFilePath, readGrants } from "../mcp-http.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface EnvironmentCommandDeps extends StatusDeps { home?: string; assets?: string }
interface WorkConnection { version: 1; mcpUrl: string; seat: string; rig: string; grantName: string; artifactRoot: string; token: string }
const managedId = (id: string): string => {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(id)) throw new Error("A short binding identifier is required");
return id;
};
function preserveWrite(file: string, data: string | Buffer): void {
mkdirSync(resolve(file, ".."), { recursive: true, mode: 0o700 });
if (existsSync(file) || (() => { try { return lstatSync(file).isSymbolicLink(); } catch { return false; } })()) {
if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile() || !readFileSync(file).equals(Buffer.from(data)))
throw new Error(`An existing file is preserved: ${file}`);
return;
}
writeFileSync(file, data, { flag: "wx", mode: 0o600 });
}
function connectionRef(home: string, bindingId: string): string { return join(home, "environment-secrets", managedId(bindingId), "work.json"); }
function readConnection(file: string): WorkConnection {
const stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384 || (stat.mode & 0o077)) throw new Error("The managed work connection must be a private regular file");
const value = JSON.parse(readFileSync(file, "utf8")) as WorkConnection;
if (value.version !== 1 || typeof value.token !== "string" || !value.token || typeof value.mcpUrl !== "string") throw new Error("The work connection is invalid");
return value;
}
async function prepareWork(home: string, input: { bindingId: string; seat: string; rig: string; mcpUrl: string; artifactRoot: string }): Promise<string> {
const url = new URL(input.mcpUrl);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.pathname.endsWith("/mcp"))
throw new Error("The work endpoint must be an HTTP(S) MCP URL without embedded credentials");
if (!/^[^\s@]+@[^\s@]+$/.test(input.seat) || !input.rig) throw new Error("A canonical seat and its rig are required");
const file = connectionRef(home, input.bindingId), grantName = `environment-${input.bindingId}`;
if (existsSync(file)) {
const prior = readConnection(file), grants = readGrants(mcpTokenFilePath(home)) ?? [];
const grant = grantFor(grants, prior.token);
if (!grant || grant.name !== grantName || prior.seat !== input.seat || prior.rig !== input.rig || prior.mcpUrl !== url.toString()
|| prior.artifactRoot !== resolve(input.artifactRoot) || grant.seat !== input.seat || grant.owner || grant.workspace
|| grant.rigs.length !== 1 || grant.rigs[0] !== input.rig || grant.roots.length !== 0) throw new Error("The existing managed work connection changed or was revoked; preserve it and reconcile before setup");
return file;
}
const token = await addGrant(mcpTokenFilePath(home), { name: grantName, seat: input.seat, rigs: [input.rig], roots: [], workspace: false, owner: false });
const data: WorkConnection = { version: 1, mcpUrl: url.toString(), seat: input.seat, rig: input.rig, grantName, artifactRoot: resolve(input.artifactRoot), token };
preserveWrite(file, JSON.stringify(data) + "\n");
return file;
}
export function environmentCommand(override?: EnvironmentCommandDeps): Command {
const cmd = new Command("environment").description("Explicit OpenShell execution placement and verified artifact return; not rig env (services)");
const deps = (): EnvironmentCommandDeps => override ?? { lifecycleDeps: realDeps(), clientFactory: url => new DaemonClient(url) };
const client = async () => { const d = deps(), status = await getDaemonStatus(d.lifecycleDeps); if (!daemonStatusGuard(status)) throw new Error("A reachable daemon is required"); return d.clientFactory(getDaemonUrl(status)); };
const home = () => deps().home ?? getOpenRigHome();
const report = (value: unknown) => console.log(JSON.stringify(value, null, 2));
const action = (fn: (...args: any[]) => Promise<void>) => async (...args: any[]) => { try { await fn(...args); } catch (error) { console.error((error as Error).message); process.exitCode = 1; } };
const checked = <T extends Record<string, any>>(res: { status: number; data: T }): T => { if (res.status >= 400) throw new Error(res.data?.error ?? `Environment request failed (${res.status})`); return res.data; };
cmd.command("setup").description("Inspect the pinned vendor CLI and install the maintained example without overwriting another preset")
.requiredOption("--directory <path>", "New or matching example directory")
.option("--binding <id>", "Prepare this binding's normal scoped MCP work connection")
.option("--seat <address>").option("--rig <name>").option("--mcp-url <url>", "Existing authenticated MCP HTTP listener, as reached by the guest")
.option("--guest-base-url <origin>", "Guest-only activity listener origin (not the main daemon API); required with work authority")
.option("--artifact-root <path>", "Host returned-artifact root, bound to this environment")
.option("--json", "JSON receipt; never credential values")
.action(action(async (opts: { directory: string; binding?: string; seat?: string; rig?: string; mcpUrl?: string; guestBaseUrl?: string; artifactRoot?: string }) => {
const binary = await promisify(execFile)("openshell", ["--version"], { timeout: 10_000, maxBuffer: 4096 });
if (!/\b0\.1\.6\b/.test(binary.stdout)) throw new Error("This reference requires OpenShell 0.1.6; no compute was prepared");
const root = resolve(opts.directory), assets = deps().assets ?? join(getDaemonPath(), "assets", "openshell");
const names = ["guest.py", "Dockerfile", "policy.yaml", "rig.yaml", "agents/worker/agent.yaml", "agents/worker/role.md", "agents/coordinator/agent.yaml", "agents/coordinator/role.md", "README.md", "driver-v1.schema.json", "claude-code-provider.yaml"];
for (const name of names) {
let data = readFileSync(join(assets, name));
if (name === "policy.yaml" && opts.mcpUrl && opts.guestBaseUrl) {
const mcp = new URL(opts.mcpUrl), activity = new URL(opts.guestBaseUrl);
if (![mcp, activity].every(url => ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash)
|| activity.pathname !== "/") throw new Error("Explicit guest endpoints without embedded credentials are required");
const mainPort = Number(process.env.OPENRIG_PORT ?? "7433");
const ports = [mcp, activity].map(url => Number(url.port || (url.protocol === "https:" ? 443 : 80)));
if (ports.includes(mainPort) || ports[0] === ports[1]) throw new Error("Select separate MCP and guest-only activity ports, never the daemon main port");
const policy = parseYaml(data.toString("utf8"));
for (const [name, url] of [["openrig_mcp", mcp], ["openrig_activity", activity]] as const) {
policy.network_policies[name].endpoints[0].host = url.hostname;
policy.network_policies[name].endpoints[0].port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
}
data = Buffer.from(stringifyYaml(policy));
}
preserveWrite(join(root, name), data);
}
preserveWrite(join(home(), "community-harnesses", "openshell-claude.yaml"), readFileSync(join(assets, "preset.yaml")));
let ref: string | undefined;
if (opts.binding || opts.seat || opts.rig || opts.mcpUrl) {
if (!opts.binding || !opts.seat || !opts.rig || !opts.mcpUrl || !opts.guestBaseUrl) throw new Error("Preparing work authority needs --binding, --seat, --rig, --mcp-url and --guest-base-url together");
ref = await prepareWork(home(), { bindingId: opts.binding, seat: opts.seat, rig: opts.rig, mcpUrl: opts.mcpUrl, artifactRoot: resolve(opts.artifactRoot ?? join(root, "artifacts")) });
}
report({ version: 1, driver: "openshell", vendorVersion: "0.1.6", directory: root,
...(ref ? { workConnectionRef: ref } : {}), effect: "reference and preset installed; no environment or native process created" });
}));
for (const verb of ["create", "attach"] as const) {
const sub = cmd.command(verb).argument("<binding>").description(verb === "create" ? "Journal intent, then explicitly create one owned sandbox; retries reconcile the same operation" : "Verify and bind an exact existing resource as an attached consumer")
.requiredOption("--seat <address>").option("--node <id>", "Existing node ID; otherwise claimed by the exact named seat on its first launch")
.requiredOption("--gateway <ref>").requiredOption("--workspace <ref>").requiredOption("--name <name>")
.requiredOption("--image <digest>").requiredOption("--provider <ref>").requiredOption("--policy <path>")
.requiredOption("--guest-base-url <origin>", "Guest-reachable guest-only activity listener origin (not the main daemon API)")
.option("--operation <id>", "Stable effect identity (default verb plus binding ID)").option("--json");
if (verb === "attach") sub.requiredOption("--resource-id <id>", "Immutable live resource ID").option("--fingerprint <value>", "Expected host-key fingerprint");
sub.action(action(async (binding: string, opts: Record<string, string>) => {
managedId(binding); const ref = connectionRef(home(), binding), work = readConnection(ref);
if (work.seat !== opts.seat) throw new Error("The managed work connection belongs to another seat; no provider operation was requested");
const body = { version: 1, bindingId: binding, operationId: opts.operation ?? `${verb}-${binding}`, nodeId: opts.node, seat: opts.seat,
baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: opts.guestBaseUrl, coordinationRef: ref, artifactRoot: work.artifactRoot,
placement: { gateway: opts.gateway, workspace: opts.workspace, name: opts.name, image: opts.image, provider: opts.provider, policy: realpathSync(opts.policy!) },
...(verb === "attach" ? { resourceId: opts.resourceId, fingerprint: opts.fingerprint } : {}) };
const res = await (await client()).post<Record<string, any>>(`/api/environments/${verb}`, body, { timeoutMs: 120_000 });
report({ ...checked(res), operationId: body.operationId, effect: res.status === 202 ? "unconfirmed; reconcile this same operation" : "binding-confirmed; native launch not requested" });
}));
}
cmd.command("inspect").description("Read stored binding receipts or inspect the exact live resource").argument("<binding>").option("--live", "Observe the exact provider resource; unavailable is not absence").option("--json")
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
.action(action(async (id: string, opts: { generation: string }) => {
// Launch and the guest latch already verify all projected files. Opening a viewer needs only one live
// resource lookup (the driver bounds it at 30s), not another serial validation of every startup file.
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 }));
if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened");
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
await new Promise<void>((resolveExit, reject) => {
const child = spawn("openshell", args, { stdio: "inherit" });
child.once("error", reject); child.once("exit", code => code === 0 ? resolveExit() : reject(new Error(`The environment viewer exited ${code}; native and compute state require inspection`)));
});
}));
cmd.command("export").argument("<binding>").requiredOption("--request <path>", "Versioned artifact request JSON with operation/task/generation/path/hash/bytes/schema/context")
.option("--json").description("Transfer actual bytes through the existing authenticated MCP work route, never an activity token")
.action(action(async (binding: string, opts: { request: string }) => {
const work = readConnection(connectionRef(home(), binding)), request = JSON.parse(readFileSync(opts.request, "utf8"));
const transport = new StreamableHTTPClientTransport(new URL(work.mcpUrl), { requestInit: { headers: { Authorization: `Bearer ${work.token}` } } });
const mcp = new Client({ name: "openrig-environment-export", version: "1" });
try {
await mcp.connect(transport);
const result = await mcp.callTool({ name: "rig_environment_export", arguments: { ...request, bindingId: binding } });
if (result.isError) throw new Error((result.content as Array<{ text?: string }>).map(c => c.text ?? "").join("\n"));
report(result.structuredContent ?? result.content);
} finally { await mcp.close(); }
}));
cmd.command("checkpoint").argument("<binding>").requiredOption("--generation <id>", "Selected current execution generation")
.requiredOption("--operation <id>", "Stable private checkpoint identity").option("--json")
.description("Capture exact private native history and verified prior artifact references while the provider remains accessible")
.action(action(async (id: string, opts: { generation: string; operation: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/checkpoint`, { operationId: opts.operation, generation: opts.generation }, { timeoutMs: 120_000 })))));
cmd.command("recreate").argument("<binding>").requiredOption("--request <path>", "JSON with operationId, predecessor generation, checkpointId, exact resumeType/resumeToken and same-host placement")
.option("--json").description("Explicitly create successor compute only after exact predecessor cleanup and retained state verification; native launch remains normal rig restore")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/recreate`, JSON.parse(readFileSync(opts.request, "utf8")), { timeoutMs: 120_000 })))));
cmd.command("completion").argument("<binding>").requiredOption("--request <path>", "JSON selecting operationId, taskId, generation, exportOperationId and stop-native or stop-compute")
.option("--json").description("Select finite completion for one task, generation and required verified export; persistent defaults stay unchanged")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8")))))));
cmd.command("effect").argument("<binding>").requiredOption("--effect <name>", "close-viewer, stop-native, stop-compute or delete-owned")
.option("--generation <id>", "Selected execution generation; omit only for delete-owned before any generation exists")
.option("--resource-id <id>", "Exact immutable compute ID; required for delete-owned without a generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--engine-socket <path>", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json")
.description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved")
.action(action(async (id: string, opts: { effect: string; generation?: string; resourceId?: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation ?? null, ...(opts.resourceId ? { resourceId: opts.resourceId } : {}), operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Run from your managed seat, or set OPENRIG_SESSION_NAME to the acting member@rig in a plain shell for existing transport attribution (not authentication). --guest-base-url names the separate guest-only activity listener, reachable from the guest; never the main API. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
return cmd;
}
+32 -8
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import fs from "node:fs";
import { Command } from "commander";
import { writeTextAtomically } from "../atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -10,6 +10,8 @@ import { rigIdForHandle } from "../rig-handle.js";
export interface ExportDeps extends StatusDeps {
writeFile: (path: string, content: string) => void;
/** Must fail with EEXIST when the path is taken. */
writeNewFile?: (path: string, content: string) => void;
exists?: (path: string) => boolean;
}
@@ -37,18 +39,35 @@ function copyCulture(file: string, root: string, outDir: string): void {
console.log(`Copied culture to ${target}`);
}
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
for (let attempt = 1; ; attempt++) {
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
try {
write(path, content);
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
return path;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
}
export function exportCommand(depsOverride?: ExportDeps): Command {
const cmd = new Command("export").description("Export a rig spec as YAML");
const getDeps = (): ExportDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
writeNewFile: writeNewText,
exists: (p) => fs.existsSync(p),
};
cmd
.argument("<rig>", "Rig name or ID to export")
.option("-o, --output <path>", "Output file path", "rig.yaml")
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
.action(async (rigId: string, opts: { output: string }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -67,9 +86,14 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
console.error(`Export failed: ${res.data}`);
process.exitCode = 1;
} else {
deps.writeFile(opts.output, res.data);
console.log(`Exported to ${opts.output}`);
for (const line of String(res.data).split("\n")) if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
const written = cmd.getOptionValueSource("output") === "default"
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
: (deps.writeFile(opts.output, res.data), opts.output);
console.log(`Exported to ${written}`);
for (const line of String(res.data).split("\n")) {
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
}
// Check for referenced culture_file and startup files resolving from the output directory
const checkExists = deps.exists ?? ((p) => fs.existsSync(p));
@@ -89,12 +113,12 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const parsed = parse(res.data) as Record<string, unknown> | null;
if (parsed && typeof parsed === "object") {
if (parsed["name"] === "starter") {
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
}
const pods = parsed["pods"] as Array<{
id?: string;
startup?: ExportedStartupBlock;
members?: Array<{ id?: string; agent_ref?: string }>;
members?: Array<{ id?: string; agent_ref?: string; startup?: ExportedStartupBlock }>;
}> | undefined;
for (const pod of Array.isArray(pods) ? pods : []) {
for (const member of Array.isArray(pod.members) ? pod.members : []) {
@@ -123,7 +147,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const startupBlocks = [
parsed["startup"] as ExportedStartupBlock | undefined,
...(Array.isArray(pods) ? pods.map((pod) => pod.startup) : []),
...(Array.isArray(pods) ? pods.flatMap((pod) => [pod.startup, ...(pod.members ?? []).map((member) => member.startup)]) : []),
];
for (const startup of startupBlocks) {
if (startup && Array.isArray(startup.files)) {
+7 -6
View File
@@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.command("add <entityId>")
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
.requiredOption("--display-name <name>", "Human-readable display name")
.requiredOption(
.option(
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
.action(async (entityId: string, opts: { displayName: string; binding?: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
// LAZY import the narrow daemon surface at invocation (dep rail 2).
const registry = await import("@openrig/daemon/gateway-human-registry");
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
@@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
for (const spec of opts.binding ?? []) {
const b = parseBindingSpec(spec);
if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; }
bindings.push(b.binding);
@@ -284,8 +284,9 @@ Example:
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`);
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
}
+78 -7
View File
@@ -1,12 +1,19 @@
// OPR.0.4.4.13 — the host-registry verbs (add / list / doctor — arch
// R13-2a: no edit/remove/tunnel/bootstrap verbs; hand-editing hosts.yaml
// remains the path for exotica; the bootstrap ships as script + runbook).
// OPR.0.4.6.MH1 FR-1 extends the family with `select` — the persisted
// host-selection pointer (kubectl current-context shape). The pointer
// lives in the config twins as `host.selected`; this verb is a THIN
// CLIENT of the daemon config write (one write path for CLI+UI
// R13-2a originally: no edit/remove/tunnel/bootstrap verbs; hand-editing
// hosts.yaml remains the path for exotica; the bootstrap ships as script +
// runbook). OPR.0.4.6.MH1 FR-1 extends the family with `select` — the
// persisted host-selection pointer (kubectl current-context shape). The
// pointer lives in the config twins as `host.selected`; this verb is a
// THIN CLIENT of the daemon config write (one write path for CLI+UI
// convergence, arch ruling); reads resolve from the local ConfigStore
// (env > file > default — zero new daemon lookups on read paths).
// Issue #2128 (maintainer-approved boundary) extends the family again with
// `edit` and `remove`: the SAME parity-pinned writer contract as add — id
// and transport stay fixed on an edit, unknown fields fail loud, empty
// string clears an optional field, and remove deletes only the registry
// entry (no credential files, no learned identity, no selection change).
// When the selected host is edited or removed, the selection outcome is
// shown plainly — never a silent switch.
//
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
// file NAMES) only — no code path in this file resolves a bearer value
@@ -18,7 +25,7 @@ import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
@@ -553,6 +560,7 @@ function printHostNextSteps(host: HostEntry): void {
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
const rewritesRegistry = "\nIt rewrites ~/.openrig/hosts.yaml from the fields it knows, so comments, formatting, unknown entry keys and other top-level keys aren't kept.";
cmd
.command("add")
@@ -566,6 +574,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
.option("--notes <text>", "Free-form operator note")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
if (opts.target !== undefined) rawEntry["target"] = opts.target;
@@ -687,6 +696,68 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
console.log(`This host is now named: ${trimmed}`);
});
cmd
.command("edit")
.description("Edit a host entry (id and transport stay fixed; an empty string clears an optional field)")
.argument("<id>", "A registered host id")
.option("--target <target>", "SSH target — ssh transport")
.option("--user <user>", "SSH user — ssh transport (empty string clears the value)")
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
const changes: Record<string, unknown> = {};
if (opts.target !== undefined) changes["target"] = opts.target;
if (opts.user !== undefined) changes["user"] = opts.user;
if (opts.url !== undefined) changes["url"] = opts.url;
if (opts.notes !== undefined) changes["notes"] = opts.notes;
const res = updateHostEntry(id.trim(), changes);
if (!res.ok) {
console.error(res.error);
process.exitCode = 1;
return;
}
const wasSelected = readSelectedHost() === res.entry.id;
if (opts.json) {
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
return;
}
console.log(`Updated host '${res.entry.id}' (${res.entry.transport}) at ${res.path}`);
for (const key of Object.keys(changes) as string[]) {
const rec = (e: HostEntry) => e as unknown as Record<string, unknown>;
const before = rec(res.previous)[key];
const after = rec(res.entry)[key];
if (before !== after) {
console.log(` ${key}: ${before === undefined ? "(unset)" : String(before)} → ${after === undefined ? "(cleared)" : String(after)}`);
}
}
if (wasSelected) console.log(`note: '${res.entry.id}' is the selected host — it was edited in place and the selection still points at it.`);
});
cmd
.command("remove")
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
.argument("<id>", "A registered host id")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { json?: boolean }) => {
const res = removeHostEntry(id.trim());
if (!res.ok) {
console.error(res.error);
process.exitCode = 1;
return;
}
const wasSelected = readSelectedHost() === res.entry.id;
if (opts.json) {
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
return;
}
console.log(`Removed host '${res.entry.id}' (${res.entry.transport}) from ${res.path}`);
if (wasSelected) console.log(`note: '${res.entry.id}' was the selected host — the selection still points at '${res.entry.id}' and was NOT changed. Pick another: rig host select <id>`);
});
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
+134 -18
View File
@@ -1,3 +1,4 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError } from "../client.js";
@@ -22,8 +23,8 @@ export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
type SeatView = { home?: string; activity?: string; peers: Peer[] };
type SwitchReport = { home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
type SwitchReport = { credentialKind?: "api-key"; home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean; effort?: string };
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
const SWITCH_TIMEOUT_MS = 120_000;
@@ -38,23 +39,25 @@ function printSeat(seat: SeatView): void {
}
}
type AccountOpts = { toAccount?: string; tokenFile?: string; storedLogin?: boolean };
type AccountOpts = { toAccount?: string; tokenFile?: string; apiKeyFile?: string; storedLogin?: boolean };
/** The account a switch names: a Codex login's saved name, a Claude token's label with its file, or a Claude seat's stored
* login. Null when neither --to-account nor --stored-login was given. */
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null } | null {
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null; apiKeyFile?: string } | null {
if (opts.apiKeyFile !== undefined && (opts.tokenFile !== undefined || opts.storedLogin)) return null;
if (opts.storedLogin) return { toAccount: "stored-login", tokenFile: null };
if (!opts.toAccount) return null;
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}) };
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}), ...(opts.apiKeyFile !== undefined ? { apiKeyFile: opts.apiKeyFile } : {}) };
}
function printClaude(claude: { label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude token: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
function printClaude(claude: { credentialKind?: "api-key"; label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude ${claude.credentialKind === "api-key" ? "API key" : "token"}: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
if (claude.instead) console.log(`instead of the stored login, the seat would use ${claude.instead}`);
if (claude.checked.length) console.log(`checked for anything that outranks it: ${claude.checked.join("; ")}`);
if (claude.notChecked.length) console.log(`not checked (the seat's /status shows what it uses): ${claude.notChecked.join("; ")}`);
}
/** Build provider views and account switches; an isolated-home switch first requires the daemon to acknowledge its plan. */
export function providerCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("provider").description(
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
@@ -510,6 +513,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--json", "Output as parseable JSON")
.action(async (opts: AccountOpts & { seat: string; json?: boolean }) => {
@@ -525,7 +529,8 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}) });
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}),
...(target.apiKeyFile !== undefined ? { apiKeyFile: target.apiKeyFile } : {}) });
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView;
claude?: Parameters<typeof printClaude>[0] }>(`/api/provider/precheck?${qs.toString()}`);
if (handleHttpError(res, "precheck")) return;
@@ -541,12 +546,19 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("switch")
.description("Move one seat to another subscription login, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.description("Move one seat to subscription or API credentials, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--force-unsafe", "Kept for scripts; a seat's checks can't be overridden, since each means nothing safe to restart or no such login")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Move to an isolated native account home with a fresh successor and handover; no shared login is changed")
.option("--reason <text>", "Required with --account-home or --clear-account-home")
.option("--model <id>", "With --account-home or --clear-account-home, the successor's model")
.option("--codex-config-profile <name>", "With --account-home or --clear-account-home, the successor's Codex profile")
.option("--dry-run", "With --account-home or --clear-account-home, show the plan without changing anything")
.option("--json", "Output as parseable JSON")
.addHelpText("after", `
The daemon plans the seat's in-place restart first (rig seat restart --plan), so a refusal changes nothing. It then saves the
@@ -568,19 +580,43 @@ to a 0600 file holding the token>. Only the path is kept, as the seat's referenc
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
own exports can't outrank it. For a running seat, it takes a snapshot and checks that the snapshot resumes the seat's
conversation. Then, holding the seat so no other stop, launch or switch runs on it in between, it sets the reference and
relaunches the seat from that snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore
resumes the same conversation in it. Work in flight when it stopped doesn't carry over. Only a refusal before Claude was
touched reads as unchanged; a relaunch that doesn't report a resumed conversation reads unknown, with the snapshot's recovery
command, and isn't retried. The relaunch uses the seat's own effort choice (rig seat set-effort) when there is one; otherwise
it asks to keep the running effort, and uses the seat's saved effort when it can't. The report's effort line says which.
A stopped seat isn't relaunched, and no snapshot is taken: the reference is saved only when the seat's next launch would
resume its conversation, and the report prints that launch (rig launch <rig> <seat> --snapshot-id <id>, or a plain rig launch
when restore's periodic carry resumes it). Otherwise it refuses and nothing changes.
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
connectors or Remote Control; its local MCP servers still work. --stored-login moves it back to the config dir's stored login;
a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the report says which token it will use. Claude's
/status shows token auth with no account email, so the label is yours, not an observed account.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean }) => {
const target = targetAccount(opts);
/status shows token auth with no account email, so the label is yours, not an observed account.
For Claude API billing use --to-account <label> --api-key-file <absolute path to a 0600 file>. The key reaches only the
resumed child's environment, never command arguments. API credentials fail closed if their file is unavailable. Existing
model, permissions, config home and conversation are retained; in-flight work is interrupted. Claude may require native
API-key approval before the first turn. OpenRig does not enforce API budgets or choose when to fall back.
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before relying on a stored login.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean; accountHome?: string; clearAccountHome?: boolean; reason?: string; model?: string; codexConfigProfile?: string; dryRun?: boolean }) => {
if (opts.apiKeyFile !== undefined && (opts.accountHome !== undefined || opts.clearAccountHome)) {
console.error("Use --api-key-file for an in-place credential switch; select or clear an account home in a separate switch.");
process.exitCode = 1; return;
}
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.toAccount !== undefined || opts.storedLogin)) {
console.error("Use --clear-account-home alone, without --account-home, --token-file, --to-account or --stored-login.");
process.exitCode = 1; return;
}
const target = opts.clearAccountHome ? { toAccount: "default" } : targetAccount(opts);
if (!target) {
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
process.exitCode = 1;
@@ -591,6 +627,85 @@ a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the r
process.exitCode = 1;
return;
}
if (opts.accountHome !== undefined || opts.clearAccountHome) {
if (!opts.reason?.trim() || opts.storedLogin) {
console.error(opts.clearAccountHome ? "Pass --reason with --clear-account-home."
: "With --account-home pass --reason and --to-account, without --stored-login. A Claude subscription may also name --token-file.");
process.exitCode = 1;
return;
}
let preview: { status: number; data: Record<string, unknown> };
try {
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
const runtime = status.data.runtime;
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
preview = await client.post<Record<string, unknown>>(`/api/seat/set-runtime/${encodeURIComponent(opts.seat)}`, {
runtime, ...(opts.clearAccountHome ? { clearAccountHome: true } : { accountHome: opts.accountHome }), reason: opts.reason, dryRun: true,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.toAccount } : {}),
}, { timeoutMs: 30_000 });
} catch {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
const token = opts.tokenFile ? { label: opts.toAccount!, tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
const refusal = { ok: false, code: "account_home_switch_unavailable", message: preview.data["message"]
?? "The daemon did not acknowledge the selected account home and token. No switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (opts.dryRun) {
if (opts.json) console.log(JSON.stringify(preview.data));
else {
console.log(`Account-home plan for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. Nothing changed.`);
const settings = (preview.data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
console.log(`Settings on ${String(preview.data["to"])}:`);
for (const [name, setting] of Object.entries(settings)) console.log(` ${name}: ${setting.value ?? "none"} (${setting.source})`);
const permission = preview.data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.mode} (${permission.source})`);
const dropped = (preview.data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${String(preview.data["from"])}: ${dropped.map(choice => `${choice.setting} ${choice.value}`).join("; ")}.`);
console.log("Costs:");
for (const cost of (preview.data["costs"] as string[] | undefined) ?? []) console.log(` - ${cost}`);
}
return;
}
let result;
try {
result = await client.post<Record<string, unknown>>("/api/provider/switch", {
seat: opts.seat, ...(opts.clearAccountHome ? { clearAccountHome: true } : { ...target, accountHome: opts.accountHome }), reason: opts.reason, dryRun: false,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
}, { timeoutMs: 300_000 });
} catch (error) {
if (!(error instanceof DaemonTimeoutError)) throw error;
result = { status: 202, data: { ok: false, code: "runtime_switch_outcome_unknown",
message: `The daemon may still be switching. Inspect rig seat status ${opts.seat}; do not retry the switch.` } };
}
if (opts.json) console.log(JSON.stringify(result.data));
else if (result.data["recoveryPending"] === true) {
console.error(`Recovery is still pending for ${opts.seat}; the account-home switch is not complete.`);
if (typeof result.data["message"] === "string") console.error(result.data["message"]);
if (typeof result.data["guidance"] === "string") console.error(result.data["guidance"]);
for (const warning of (result.data["warnings"] as string[] | undefined) ?? []) console.error(`Warning: ${warning}`);
}
else if (result.data["ok"] === true) console.log(`Account-home switch for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. A fresh conversation carries the handover; the account is not verified until a real tool turn.`);
else console.error(result.data["message"] ?? "The account-home switch did not complete.");
process.exitCode = result.status >= 400 || result.data["ok"] !== true || result.data["recoveryPending"] === true ? 1 : 0;
return;
}
if (opts.dryRun || opts.reason || opts.model || opts.codexConfigProfile) {
console.error("--reason, --model, --codex-config-profile and --dry-run require --account-home or --clear-account-home.");
process.exitCode = 1;
return;
}
type SwitchResponse = { status: number; data: { outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport } };
let res: SwitchResponse;
const startedAt = new Date().toISOString();
@@ -622,9 +737,10 @@ a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the r
console.log("undo restores saved credential bytes only; sign-in is unverified. Saved credentials may expire, be revoked, or be superseded by refresh elsewhere. Restart the affected seat and use the existing account verification; fresh sign-in may be required.");
}
} else if (seat) {
console.log(`Claude token: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
console.log(`Claude ${seat.credentialKind === "api-key" ? "API key" : "token"}: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
if (seat.effort) console.log(`effort: ${seat.effort}`);
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
}
+40 -5
View File
@@ -356,10 +356,29 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
function queuePriorityArg(value: string): string {
const lower = value.toLowerCase();
if (QUEUE_PRIORITIES.includes(lower)) return lower;
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
return value;
}
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
const PRE_WRITE_CREATE_REFUSALS = new Set([
"human_route_fields_required", "invalid_human_notification", "invalid_human_questions", "reply_to_requires_update", "reply_to_not_found",
]);
/** #1642: why a create provably wrote nothing, for the refusals that come before any write: the
* selected daemon refused the forward before contacting another host (its registry was unreadable,
* the host unknown or SSH-declared), or refused a local create to a rig it doesn't have. Any other
* failure, including a timeout or a remote's error, may follow a commit, so it returns null. */
* the host unknown or SSH-declared), refused a local create to a rig it doesn't have, or refused the
* new row's fields while validating it. Any other failure, including a timeout or a remote's error,
* may follow a commit, so it returns null. */
function provenCreateRefusal(status: number, body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const { error, failureClass, hostId } = body as { error?: unknown; failureClass?: unknown; hostId?: unknown };
@@ -370,6 +389,9 @@ function provenCreateRefusal(status: number, body: unknown): string | null {
if (status === 400 && error === "unknown_destination_rig") {
return "the daemon refused it because the destination's rig doesn't exist on that daemon.";
}
if (status === 400 && typeof error === "string" && PRE_WRITE_CREATE_REFUSALS.has(error)) {
return `the daemon refused it before writing anything (${error}).`;
}
return null;
}
@@ -628,6 +650,18 @@ async function printQueueItemResult(
// IMPL-SPEC §2.3-2.4.
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
function warnIfUnknownStates(value?: string): void {
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
if (unknown.length === 0) return;
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
}
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
@@ -900,7 +934,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
@@ -1374,7 +1408,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1478,7 +1512,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1761,6 +1795,7 @@ Examples:
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
warnIfUnknownStates(opts.state);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
+132 -20
View File
@@ -1,3 +1,4 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
@@ -62,13 +63,15 @@ interface SeatStatusResponse {
};
session_status: string | null;
launch_cwd?: string | null;
permissions_to?: string | null;
runner?: boolean;
runtime_home?: {
variable: string;
home: string | null;
source: "member" | "rig" | "daemon" | "native_default";
source: "member" | "rig" | "daemon" | "native_default" | "account-switch";
ignored?: { source: string; value: string };
};
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
session_reason?: string | null;
startup_status: string | null;
occupant_lifecycle: string;
@@ -164,11 +167,19 @@ interface SeatHandoverMutationResult {
restorePacket?: { status: "unconfirmed"; reason: string };
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
departingProcess?: "ended-graceful" | "ended-forced" | "not-touched";
startupContextDelivered: boolean;
provenanceRecordWritten: false;
provenanceRecordWritten: boolean;
};
}
const DEPARTING_PROCESS_LINES = {
"ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.",
"ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.",
"not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.",
} as const;
interface SeatSwitchClientResponse {
seat_ref: string;
session: string;
@@ -193,22 +204,28 @@ const LEVEL_WORDS: Record<string, string> = {
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
};
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
console.log(`Rig: ${status.rig_name}`);
console.log(`Logical ID: ${status.logical_id}`);
console.log(`Current occupant: ${display(status.current_occupant)}`);
if (status.launch_cwd !== undefined) console.log(`Working directory for future launches: ${display(status.launch_cwd)}`);
// Only a runner submits permission requests; a plain seat's own prompts are its harness's.
if (status.permissions_to || status.runner) {
console.log(`Runner permission requests answered by: ${status.permissions_to === "deny" ? "no one: refused (--deny)" : status.permissions_to ?? "the default person"}`);
}
if (status.runtime_home) {
const h = status.runtime_home;
const from = h.source === "member" || h.source === "rig" ? `the seat's own, from its ${h.source} env`
: h.source === "account-switch" ? "the seat's selected account home"
: h.source === "daemon" ? "the daemon's" : "the native default";
console.log(`Runtime home (${h.variable}) for launches: ${display(h.home)} (${from}; configured, not proof of the login a running session uses)`);
if (h.ignored) console.log(` Ignored: its ${h.ignored.source} env sets ${h.variable}=${h.ignored.value}, which isn't an absolute path`);
}
if (status.claude_token) {
const t = status.claude_token;
console.log(`Claude subscription token: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
console.log(`Claude ${t.kind === "api-key" ? "API key" : "subscription token"}: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
if (t.notice) console.log(` ${t.notice}`);
}
if (status.typingGuard) {
@@ -316,6 +333,13 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
if (result.sideEffects.departingProcess) {
console.log(result.sideEffects.provenanceRecordWritten
? "The handover's lineage record (occupant tenure) was written."
: "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]);
}
// OPR.0.5.5.5 — the per-source execution record: what actually carried context.
if (result.sourceOutcome?.mode === "fork") {
console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`);
@@ -340,18 +364,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
// live successor agent.
console.log("The captured startup context (restore packet) was delivered to the successor.");
}
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
} else {
// discovered handover: the operator-prepared successor is already live, so no
// separate context delivery is performed.
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
}
}
@@ -378,6 +402,7 @@ function printSeatError(error: SeatStatusError, fallback: string): void {
}
}
/** Build seat lifecycle commands, requiring acknowledged plans before isolated account-home changes. */
export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promise<string>; host?: RunOnHostDeps }): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
@@ -529,7 +554,7 @@ Examples:
cmd
.command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<id> (native fork of the source conversation), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--source <source>", "Source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<seat> (native fork of that seat's conversation, by its session name), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
@@ -669,7 +694,7 @@ identity remains unverified; a version number alone cannot clear it.
// Thin CLI over the daemon's SeatLifecycleService; refusals print message +
// guidance + match list exactly as the daemon named them.
const runLifecycleVerb = async (
path: "set-terminal-policy" | "set-model" | "set-cwd" | "set-effort" | "set-permissions" | "launch" | "launch-plan" | "continue" | "stop" | "clean" | "restart" | "restart-plan" | "restart-verify",
path: "set-terminal-policy" | "set-model" | "set-cwd" | "set-effort" | "set-permissions" | "set-permissions-to" | "launch" | "launch-plan" | "continue" | "stop" | "clean" | "restart" | "restart-plan" | "restart-verify",
seat: string,
body: Record<string, unknown>,
opts: { json?: boolean },
@@ -852,6 +877,49 @@ Examples:
});
});
cmd
.command("set-permissions-to")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--to <person>", "The registered person who answers this seat's runner permission requests instead of the default person, such as mike@external")
.option("--clear", "Remove the override: the default person answers again")
.option("--deny", "Opt in to refusing every runner permission request on this seat")
.requiredOption("--reason <text>", "Audit reason recorded on the node.permissions_to_changed event")
.option("--operator <address>", "Operator recorded on the audit event (required outside a managed seat)")
.option("--json", "JSON output for agents")
.description("Choose who answers a runner seat's permission requests instead of the default person, or opt in to refusing them (audited)")
.addHelpText("after", `
A runner seat's agent can ask permission for a tool call. The runner sends the
request to OpenRig, and one person gets an ordinary ask with every option the agent
offered; the answer goes back to that one request. That person is the default person
(rig gateway default), unless this override names someone else. When no one can be
asked, OpenRig's default is to permit the request.
Only a registered person can be chosen (rig gateway human list). A seat address is
refused. --clear removes the override. --deny is the opt-in for a seat whose requests
should all be refused. See docs/reference/harness-runner-contract.md, section 7.
Examples:
rig seat set-permissions-to dev-runner@my-rig --to mike@external --reason "Mike approves tool calls"
rig seat set-permissions-to dev-runner@my-rig --deny --reason "high-stakes repo: refuse every request"
rig seat set-permissions-to dev-runner@my-rig --clear --reason "back to the default person" --json`)
.action(async (seat: string, opts: { to?: string; clear?: boolean; deny?: boolean; reason: string; operator?: string; json?: boolean }) => {
if ([Boolean(opts.clear), Boolean(opts.deny), opts.to !== undefined].filter(Boolean).length !== 1) {
console.error("Pass exactly one of --to <person>, --clear or --deny.");
process.exitCode = 1;
return;
}
const body = opts.clear ? { clear: true, reason: opts.reason, operator: opts.operator }
: opts.deny ? { deny: true, reason: opts.reason, operator: opts.operator }
: { to: opts.to, reason: opts.reason, operator: opts.operator };
await runLifecycleVerb("set-permissions-to", seat, body, opts, (data) => {
const s = data["seat"] as { logicalId?: string; rigName?: string } | undefined;
const name = `${s?.logicalId}@${s?.rigName}`;
const owner = (value: unknown) => value === "deny" ? "refused (--deny)" : typeof value === "string" ? value : "the default person";
console.log(data["changed"] === false
? `${name}'s permission requests already go to ${owner(data["to"])}. No change recorded.`
: `${name}'s permission requests: ${owner(data["from"])} -> ${owner(data["to"])} (audited).`);
});
});
cmd
.command("effort")
.argument("<seat>", "Canonical session name or logical seat ref")
@@ -919,6 +987,10 @@ resume/successor launch reads it at call time. Examples:
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
.option("--account-label <label>", "With --token-file, the selected account's label")
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
.option("--confirm-timeout <seconds>", "How long the successor has to confirm the handover (default 180)")
.option("--dry-run", "Show the plan (settings, what is cleared, costs) without changing anything")
@@ -941,6 +1013,11 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
processes run until cutover; messages sent to the seat during the switch are
held, not typed, and need sending again afterwards.
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit" --dry-run
@@ -948,7 +1025,11 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat status dev-impl@my-rig # an open or interrupted switch shows here
rig seat set-runtime dev-impl@my-rig --recover`)
.action(async (seat: string, opts: { runtime?: string; reason?: string; model?: string; effort?: string; codexConfigProfile?: string;
permissionMode?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
permissionMode?: string; accountHome?: string; clearAccountHome?: boolean; tokenFile?: string; accountLabel?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.accountLabel !== undefined || opts.runtime === undefined)) {
console.error("Use --clear-account-home with --runtime and without --account-home, --token-file or --account-label.");
process.exitCode = 1; return;
}
const modes = [opts.confirm !== undefined, opts.recover === true, opts.runtime !== undefined].filter(Boolean).length;
if (modes !== 1) {
console.error("Pass exactly one of --runtime <runtime> (with --reason), --confirm <switch-id> (with --note), or --recover.");
@@ -980,19 +1061,42 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
const client = deps.clientFactory(getDaemonUrl(daemon));
const base = `/api/seat/set-runtime/${encodeURIComponent(seat)}`;
let res;
let switchRequested = false;
try {
res = opts.confirm !== undefined
? await client.post<Record<string, unknown>>(`${base}/confirm`, { switchId: opts.confirm, note: opts.note })
: opts.recover
? await client.post<Record<string, unknown>>(`${base}/recover`, {}, { timeoutMs: 60_000 })
: await client.post<Record<string, unknown>>(base, {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
}, { timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
: await (async () => {
const body = {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(opts.accountHome !== undefined ? { accountHome: opts.accountHome } : {}),
...(opts.clearAccountHome ? { clearAccountHome: true } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.accountLabel ?? "selected-account" } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
};
if (opts.accountHome !== undefined || opts.clearAccountHome) {
const preview = await client.post<Record<string, unknown>>(base, { ...body, dryRun: true }, { timeoutMs: 30_000 });
const token = opts.tokenFile ? { label: opts.accountLabel ?? "selected-account", tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
return { status: 409, data: { ok: false, code: "account_home_switch_unavailable",
message: preview.data["message"] ?? "The daemon did not acknowledge the selected account home and token. No switch was requested." } };
}
if (opts.dryRun) return preview;
}
switchRequested = true;
return client.post<Record<string, unknown>>(base, body,
{ timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
})();
} catch (err) {
if ((opts.accountHome !== undefined || opts.clearAccountHome) && !switchRequested) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (!(err instanceof DaemonTimeoutError) || opts.dryRun) throw err;
const unknown = { ok: false as const, code: "runtime_switch_outcome_unknown", status: "unknown",
message: "The CLI stopped waiting; the daemon may still be switching the seat.",
@@ -1026,7 +1130,12 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
else if (data["recoveryPending"] === true) console.log(`Recovery is still pending for ${name} (switch ${String(data["switchId"])}); the runtime switch is not complete.`);
else console.log(`Switched ${name} from ${String(data["from"])} to ${String(data["to"])} (switch ${String(data["switchId"])}).`);
if (data["accountHome"] === null) console.log("Selected account home: configured defaults (member/rig, then daemon, then native)");
else if (data["accountHome"]) console.log(`Selected account home: ${String(data["accountHome"])}`);
const accountToken = data["accountToken"] as { label: string; tokenFile: string } | undefined;
if (accountToken) console.log(`Claude token reference: ${accountToken.label}, read from ${accountToken.tokenFile} at launch`);
console.log(`Settings on ${String(data["to"])}:`);
for (const [key, title] of [["model", "model"], ["effort", "effort"], ["advisor", "advisor"], ["codexConfigProfile", "Codex profile"]] as const) {
const setting = settings[key];
@@ -1280,6 +1389,8 @@ session is refused (use rig unclaim). Stopping ends that agent's session and any
work in progress: check it first (rig capture <seat>) and stop only a seat you
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
a fresh occupant there; it does not resume the stopped conversation.
A community harness stop closes its managed pane; its native processes or compute
may outlive it. Inspect their own lifecycle receipts.
An intentionally stopped seat does not degrade an otherwise running rig.
Running counts still exclude the stopped seat; stopping every seat leaves the
rig stopped. Examples:
@@ -1290,6 +1401,7 @@ rig stopped. Examples:
const s = data["seat"] as { logicalId?: string; rigName?: string } | undefined;
console.log(`Stopped ${String(data["sessionName"])} (seat ${s?.logicalId}@${s?.rigName}).`);
console.log("Session marked exited, binding cleared; siblings untouched. Relaunch via the normal launch surface.");
if (data["paneOnly"]) console.log("Only the community seat viewer was closed. Native execution, storage and compute may remain; inspect the selected environment or harness lifecycle.");
});
});
@@ -1464,7 +1576,7 @@ export function handoverCommand(depsOverride?: SeatDeps): Command {
};
return new Command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Successor source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<id> (native fork of the source conversation), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--source <source>", "Successor source: fresh (default; launches a new agent), discovered:<id> (operator-prepared), fork:<seat> (native fork of that seat's conversation, by its session name), or rebuild (fresh agent primed from the seat's durable artifacts).")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
@@ -1472,8 +1584,8 @@ export function handoverCommand(depsOverride?: SeatDeps): Command {
.description("Hand a seat to a successor: create -> deliver context -> verify continuity -> rebind")
.addHelpText("after", `
All four sources execute (OPR.0.5.5.5): fresh delivers a captured restore
packet; discovered adopts an operator-prepared live session; fork:<id> launches
a NATIVE FORK of the source conversation (the successor carries the incumbent
packet; discovered adopts an operator-prepared live session; fork:<seat> launches
a NATIVE FORK of that seat's conversation (the successor carries the incumbent
context from its first byte); rebuild launches fresh and primes from the seat's
durable artifact chain, recording exactly what it found. A source that cannot
proceed (e.g. fork with no discoverable native id) refuses honestly before any
+17
View File
@@ -928,8 +928,24 @@ tmux scrollback:
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
// the primary surface for the new-operator sequence (status/doctor only
// HINT back to it; the durable reference is docs/reference/getting-started.md).
// A failed doctor check also leaves setup not ready, so it's named below. So is the port check's warning, though setup
// stays ready: the configured port's daemon isn't confirmed as this user's, such as another user's daemon. Provider
// checks are the setup steps themselves, reused, so they aren't listed twice.
const stepIds = new Set(result.steps.map(step => step.id));
const doctorAttention = result.verification?.checks.filter(check => !stepIds.has(check.name)
&& (check.status === "fail" || (check.name === "port" && check.status === "warn"))) ?? [];
const listChecks = () => {
for (const check of doctorAttention) {
console.log(` ${check.name}: ${check.message}`);
if (check.fix) console.log(` Fix: ${check.fix}`);
}
};
if (result.ready) {
console.log("\nSetup complete.\n");
if (doctorAttention.length) {
console.log("Some steps need attention:");
listChecks();
}
} else {
console.log("\nSome steps need attention:");
if (opts.dryRun) console.log("Plan only: no setup changes were made; readiness was not checked.");
@@ -937,6 +953,7 @@ tmux scrollback:
console.log(` ${step.id}: ${step.message}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
listChecks();
console.log("Only the harnesses selected for your project need a login; an unused harness does not (rerun with --providers to leave it out).");
console.log("Run `rig doctor` to recheck the system and provider authentication.");
}
+2
View File
@@ -186,6 +186,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (opts) => {
@@ -200,6 +201,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
sourceLabel: opts.sourceLabel ?? cur.sourceLabel,
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
};
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
const result = await runChannelOperation({
+35 -10
View File
@@ -20,6 +20,14 @@ import { daemonMayAnswer } from "../daemon-reachability.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const KNOWN_SPEC_KINDS = ["rig", "agent", "workflow"] as const;
function warnIfUnknownKind(kind?: string): void {
if (kind !== undefined && !KNOWN_SPEC_KINDS.includes(kind as (typeof KNOWN_SPEC_KINDS)[number])) {
console.error(`Warning: unrecognized --kind '${kind}'. Supported kinds: ${KNOWN_SPEC_KINDS.join(", ")}`);
}
}
interface LibraryEntry {
id: string;
kind: "rig" | "agent" | "workflow";
@@ -147,10 +155,16 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: LibraryEntry["kind"] },
opts?: { kind?: string },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
const byId = entries.find((e) => e.id === nameOrId);
@@ -219,11 +233,16 @@ Examples:
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library";
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const entries = res.data ?? [];
const rawEntries = res.data ?? [];
const entries =
opts.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
@@ -231,7 +250,11 @@ Examples:
}
if (entries.length === 0) {
console.log("No specs in library. Add specs with: rig specs add <path>");
if (opts.kind !== undefined) {
console.log(opts.kind ? `No ${opts.kind} specs found` : "No specs found");
} else {
console.log("No specs in library. Add specs with: rig specs add <path>");
}
return;
}
@@ -250,10 +273,11 @@ Examples:
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -278,10 +302,11 @@ Examples:
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
const review = res.data;
const teamChoice = entry.sourceType === "builtin" && entry.kind === "rig" && ["starter", "factory"].includes(entry.name)
+3 -1
View File
@@ -20,7 +20,9 @@ export interface StatusDeps {
function formatSnapshotAge(snapshotAt: string | null): string {
if (!snapshotAt) return "none";
const now = Date.now();
const then = new Date(snapshotAt).getTime();
// Snapshot times are SQLite `datetime('now')` stamps: UTC written without a zone, which Date reads as local time.
const then = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(snapshotAt)
? `${snapshotAt.replace(" ", "T")}Z` : snapshotAt);
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return "just now";
+34 -3
View File
@@ -1,10 +1,11 @@
// OPR.0.4.6.02 C3 — the `rig terminal` command family (the TUI-1 opaque verb +
// the operator's terminal-provider-ride entry). Three subcommands, all hitting
// the operator's terminal-provider-ride entry). The subcommands read
// the ONE canonical daemon composer (`/api/terminal/...`):
//
// rig terminal open <view> [--provider herdr|cmux|rex] [--json]
// rig terminal views [--json]
// rig terminal status [--provider herdr|cmux|rex] [--json]
// rig terminal attach <session> [--read-only]
//
// `<view>` resolves daemon-side: a rig name (per-rig derived) | `mission:<id>` |
// `slice:<id>` (derived) | a saved-view id. The result is the ONE shared
@@ -22,8 +23,12 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
import { assertLocalTmux, attachTmux, isLocalTmuxHost, readTmuxServerArgs, type TmuxAttachment } from "../tmux-attachment.js";
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
export interface TerminalDeps extends StatusDeps {
windowDeps?: WindowDeps;
attachTmux?: (target: TmuxAttachment) => Promise<number>;
}
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
@@ -132,7 +137,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
@@ -220,5 +225,31 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
});
});
cmd.command("attach <session>")
.description("Attach this terminal to a session on the selected daemon's tmux server")
.option("--read-only", "View the session without sending input")
.action(async (session: string, opts: { readOnly?: boolean }) => {
const deps = getDeps();
await withClient(deps, async client => {
try {
if (client.baseUrl && !isLocalTmuxHost(client.baseUrl)) {
throw new Error("Terminal attachment uses local tmux; run this command on the selected daemon's machine.");
}
const response = await client.get<{ tmux?: { mode?: "inherit" | "isolated"; serverArgs?: unknown } }>("/api/terminal/status");
if (response.status >= 400) throw new Error(`Cannot read the tmux server (HTTP ${response.status}).`);
if (response.data.tmux?.serverArgs === undefined) {
throw new Error("This daemon cannot report its tmux server. Update and restart it before using terminal attach.");
}
await assertLocalTmux(client, response.data.tmux.mode);
process.exitCode = await (deps.attachTmux ?? attachTmux)({
session, serverArgs: readTmuxServerArgs(response.data.tmux.serverArgs), readOnly: opts.readOnly ?? false,
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
});
});
return cmd;
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { openMissionControl, USAGE_LINES, type FrontDoorIo } from "../front-door.js";
import { sharedTuiTarget, attachSharedTui } from "../shared-tui.js";
import type { TmuxAttachment } from "../tmux-attachment.js";
/** The registry-entry shape `rig tui commands` serializes (REGISTRY I2, ruling 64f1dbdf).
* Structural mirror of the TUI's CommandEntry data fields (functions are not serialized). */
@@ -38,8 +39,8 @@ async function loadRegistryFromDist(baseDir: string): Promise<TuiCommandEntry[]>
export function tuiCommand(io: FrontDoorIo & {
loadRegistry?: () => Promise<TuiCommandEntry[]>;
sharedTarget?: () => Promise<string>;
attachShared?: (target: string) => Promise<number>;
sharedTarget?: () => Promise<TmuxAttachment>;
attachShared?: (target: TmuxAttachment) => Promise<number>;
} = {}): Command {
const cmd = new Command("tui")
.description("open the status dashboard, not agent chat (--shared is the OpenRig view fallback)")
+11 -2
View File
@@ -87,7 +87,10 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
.description(
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
)
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
.option(
"--kind <kind>",
"Workspace kind to validate against: user | project | knowledge | lab | delivery",
)
.option("--no-recursive", "Do not descend into subdirectories")
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
@@ -125,7 +128,13 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
maxFiles,
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
if (opts.json) {
console.error(JSON.stringify(res.data, null, 2));
} else {
const data = res.data as { message?: string; error?: string } | null | undefined;
const msg = data?.message ?? data?.error ?? `Validate failed (HTTP ${res.status})`;
console.error(msg);
}
process.exitCode = 1;
return;
}
+12
View File
@@ -165,6 +165,8 @@ export interface RiggedConfig {
// `rig config get/set terminal.status_bar` surface + twin parity.
terminal: {
statusBar: boolean;
/** Applied at daemon startup; a live previous server is retained until drained. */
tmuxServer: "inherit" | "isolated";
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
historyLimit: number;
inheritTmuxEnvironment: boolean;
@@ -355,6 +357,7 @@ const DEFAULTS = {
// OPR.0.4.6.02 S1 — inner-tmux status bar OFF at launch by default.
terminal: {
statusBar: false,
tmuxServer: "inherit",
historyLimit: 10000,
inheritTmuxEnvironment: false,
},
@@ -504,6 +507,7 @@ export const VALID_KEYS = [
// boolean (default off), lockstep with the daemon settings-store twin
// (the parity test pins both). Flip is future-launches-only (BR-1).
"terminal.status_bar",
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
"terminal.history_limit",
"terminal.inherit_tmux_environment",
@@ -626,6 +630,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
@@ -724,6 +729,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
@@ -950,6 +956,11 @@ function percentageConstraint(key: string) {
}
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
@@ -1329,6 +1340,7 @@ export class ConfigStore {
},
terminal: {
statusBar: v("terminal.status_bar") as boolean,
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
historyLimit: v("terminal.history_limit") as number,
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
},
+3 -1
View File
@@ -72,7 +72,9 @@ export function resolveCrossHostTarget(
const suffix = rawTarget.slice(lastAt + 1);
const unregisteredHint = suffix.length > 0
? `no registered host '${suffix}' — if '${suffix}' was meant as a host, check \`rig host ls\``
? `no registered host '${suffix}' — if '${suffix}' is another machine's self-id (the address a cross-host message shows), `
+ `run \`rig host doctor <alias>\` for that machine's entry if it uses http (it learns the id), or set \`hostId: ${suffix}\` on that entry in hosts.yaml (any transport); `
+ `\`rig host ls\` lists the registered hosts`
: undefined;
if (suffix.length === 0 || base.length === 0) {
+67 -4
View File
@@ -62,6 +62,9 @@ export interface DaemonStatus {
eventLoop?: DaemonEventLoopEvidence;
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
probeErrorCode?: string;
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
startPending?: { pid: number; since?: string };
}
/** "unknown": the process may exist but this shell can neither signal nor inspect it. */
@@ -137,6 +140,16 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
...causeCode,
};
}
// A refused probe under a live start reservation: a launch is in progress and nothing is listening yet.
if (status.startPending) {
const since = status.startPending.since ? ` since ${status.startPending.since}` : "";
return {
fact: `Daemon is starting (launch reserved by pid ${status.startPending.pid}${since}); it isn't listening yet.`,
consequence: "This command needs a responsive daemon.",
action: "Wait, then re-check with 'rig daemon status'.",
...causeCode,
};
}
// unverified, or running-but-unhealthy: we do NOT know it is down.
return {
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
@@ -842,6 +855,13 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (unverified) {
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
}
// Expiring the readiness budget is not evidence that this owned child failed.
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
assertChild();
if (deps.isProcessAlive(pid!)) {
assertChild();
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
}
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
}
assertChild();
@@ -1036,6 +1056,40 @@ function findSiblingHome(deps: LifecycleDeps): DaemonStatus["siblingHint"] {
return undefined;
}
/** T2 — read-only: the live launcher (else child) PID named by a start reservation, or undefined
* when there is no reservation, it is unreadable, or every PID it names is dead. */
function liveStartReservation(deps: LifecycleDeps): DaemonStatus["startPending"] {
try {
const file = path.join(deps.homeDir ?? OPENRIG_DIR, "daemon-start.lock");
const raw = deps.exists(file) ? deps.readFile(file) : null;
if (!raw) return undefined;
let launcherPid: number | undefined;
let childPid: number | undefined;
let since: string | undefined;
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const rec = JSON.parse(line) as { launcherPid?: unknown; childPid?: unknown; startedAt?: unknown };
if (typeof rec.launcherPid === "number") launcherPid = rec.launcherPid;
if (typeof rec.childPid === "number") childPid = rec.childPid;
if (typeof rec.startedAt === "string") since = rec.startedAt;
} catch {
// partial line: keep whatever parsed
}
}
for (const pid of [launcherPid, childPid]) {
if (pid === undefined) continue;
const liveness: ProcessLiveness = deps.processLiveness
? deps.processLiveness(pid)
: deps.isProcessAlive(pid) ? "alive" : "dead";
if (liveness !== "dead") return { pid, ...(since ? { since } : {}) };
}
} catch {
// best-effort only: a status read must never fail on the reservation
}
return undefined;
}
export async function getDaemonStatus(
deps: LifecycleDeps,
options: GetDaemonStatusOptions = {},
@@ -1088,9 +1142,13 @@ export async function getDaemonStatus(
// 1ae863d2: the resolved home has NO daemon state — before asserting anything,
// look for a live sibling home / HOME-MOVED marker (the wrong-home class).
const siblingHint = findSiblingHome(deps);
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err) };
return isRefusedError(err)
? { state: "stopped" }
// T2: a refused listener under a live start reservation is a launch in progress, not down.
const refused = isRefusedError(err);
const startPending = refused ? liveStartReservation(deps) : undefined;
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err), ...(startPending ? { startPending } : {}) };
if (refused && !startPending) return { state: "stopped" };
return refused
? { state: "unverified", port: configured.port, host: configured.host, startPending, ...probeErrorCodeOf(err) }
: { state: "unverified", port: configured.port, host: configured.host, ...probeErrorCodeOf(err) };
}
}
@@ -1118,7 +1176,12 @@ export async function getDaemonStatus(
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
} catch (err) {
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
// T2: unless a live start reservation says a launch is in progress.
if (isRefusedError(err)) {
const startPending = liveStartReservation(deps);
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
}
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
}
}
+3 -2
View File
@@ -58,13 +58,14 @@ export function getOfflineHelpPath(): string | undefined {
}
/** Whether a command should make its real request: false only when the daemon is confirmed down
* (stopped, a stale record, or a refused health probe, meaning nothing is listening), when this
* (stopped, a stale record, or a refused health probe, meaning nothing is listening, including one under a
* pending start, whose `startPending` is set only on a refused probe), when this
* machine blocked the health probe, or when a live sibling home suggests the request would reach
* another home's daemon. A daemon that is alive but busy (its health check slow, failing or
* reporting a starved loop) gets the request, under its normal timeout. */
export function daemonMayAnswer(status: DaemonStatus): boolean {
if (status.state === "stopped" || status.state === "stale") return false;
if (status.probeErrorCode === "ECONNREFUSED") return false;
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
return true;
}
+8 -4
View File
@@ -59,7 +59,8 @@ export interface DestroyDeps {
removePath: (path: string) => void;
mkdirp: (path: string) => void;
listManagedTmuxSessions: (dbPath: string) => string[];
killTmuxSession: (sessionName: string) => boolean;
resolveTmuxServerArgs?: (stateRoot: string) => string[];
killTmuxSession: (sessionName: string, serverArgs?: readonly string[]) => boolean;
sleep: (ms: number) => Promise<void>;
now: () => Date;
}
@@ -179,6 +180,9 @@ function isLocalHost(host: string): boolean {
}
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
// Resolve before stopping the daemon or moving its state. An unreadable record
// must not turn a managed-session cleanup into a kill on the caller's server.
const serverArgs = plan.scope === "all" ? deps.resolveTmuxServerArgs?.(plan.stateRoot) : undefined;
const warnings = [...plan.warnings];
const backupPaths: string[] = [];
let stateRecreated = false;
@@ -239,7 +243,7 @@ export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Prom
let tmuxMissing = 0;
if (plan.scope === "all") {
for (const sessionName of plan.managedTmuxSessions) {
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
if (deps.killTmuxSession(sessionName, serverArgs)) tmuxKilled += 1;
else tmuxMissing += 1;
}
}
@@ -284,9 +288,9 @@ export function findListeningPidWithLsof(port: number): number | null {
}
}
export function killTmuxSessionWithCli(sessionName: string): boolean {
export function killTmuxSessionWithCli(sessionName: string, serverArgs: readonly string[] = []): boolean {
try {
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
execFileSync("tmux", [...serverArgs, "kill-session", "-t", `=${sessionName}`], { stdio: "ignore" });
return true;
} catch {
return false;
+1 -1
View File
@@ -161,7 +161,7 @@ export function recordHostObservation(args: {
function recordUnderLock(alias: string, observedHostId: string, observedUrl: string | undefined, at: string, path: string): HostObservationOutcome {
const file = loadHostBindings(path);
const existing = file.bindings[alias];
const existing = Object.hasOwn(file.bindings, alias) ? file.bindings[alias] : undefined;
let result: HostObservationOutcome;
if (!existing) {
+125
View File
@@ -372,3 +372,128 @@ export function addHostEntry(rawEntry: Record<string, unknown>, path: string = d
}
return { ok: true, path, entry };
}
// ---------------------------------------------------------------------------
// OPR.0.4.6.MH1 FR-1 extension (issue #2128, maintainer boundary) — the
// edit/remove write paths, through the SAME ONE write contract as add:
// load-validate (a present-but-invalid registry is NEVER overwritten),
// candidate through validateHostRegistry (add-time errors are load-time
// errors, verbatim), atomic tmp+rename at 0600.
//
// Boundary pinned here: an edit keeps the host's id and transport FIXED;
// unknown fields fail loud with the allowed set (a typo'd flag must never
// silently no-op an edit); an empty string clears an OPTIONAL field (user,
// notes) — clearing is how a wrong value is dropped, while required fields
// (target, url) keep the validator's non-empty rule; remove deletes ONLY the
// registry entry (no credential files, no learned identity, no selection).
//
// The daemon twin (packages/daemon/src/domain/hosts/hosts-registry-writer.ts)
// mirrors updateHostEntry/removeHostEntry verbatim; byte parity is pinned by
// packages/daemon/test/hosts-registry-parity.test.ts.
// ---------------------------------------------------------------------------
/** Fields an edit may touch, per transport. id/transport never appear here. */
const EDITABLE_FIELDS: Record<"ssh" | "http", readonly string[]> = {
ssh: ["target", "user", "notes"],
http: ["url", "notes"],
};
/** Optional editable fields an empty string CLEARS (drops the key). */
const CLEARABLE_FIELDS = new Set(["user", "notes"]);
export type UpdateHostResult =
| { ok: true; path: string; entry: HostEntry; previous: HostEntry }
| { ok: false; error: string };
export type RemoveHostResult =
| { ok: true; path: string; entry: HostEntry }
| { ok: false; error: string };
/** The write-path load: MISSING is an error here (nothing to edit/remove) with
* the loader's canonical message; INVALID is a loud refusal, never clobbered. */
function loadExistingForWrite(path: string): { ok: true; existing: HostEntry[] } | { ok: false; error: string } {
const loaded = loadHostRegistry(path);
if (loaded.ok) return { ok: true, existing: loaded.registry.hosts };
const prefix = existsSync(path) ? "refusing to modify an invalid registry: " : "";
return { ok: false, error: prefix + loaded.error };
}
/** Unknown-id error for a WRITE: names the id, lists known ids (resolveHost's shape). */
function unknownHostIdError(existing: HostEntry[], id: string): string {
const knownIds = existing.map(h => h.id).slice(0, 10);
const hint = knownIds.length > 0
? ` Known host ids: ${knownIds.join(", ")}${existing.length > knownIds.length ? ` (+${existing.length - knownIds.length} more)` : ""}.`
: " (registry is empty)";
return `unknown host id '${id}'.${hint}`;
}
/** The atomic registry write the write contract shares (tmp+rename, 0600). */
function writeRegistryFile(path: string, hosts: HostEntry[]): string | null {
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`);
writeFileSync(tmp, yaml().stringify({ hosts }), { mode: 0o600 });
renameSync(tmp, path);
return null;
} catch (err) {
return `failed to write host registry at ${path}: ${(err as Error).message}`;
}
}
/** Apply an edit to ONE entry: id/transport fixed, unknown fields refused,
* empty string clears an optional field, then the WHOLE registry validates. */
export function updateHostEntry(id: string, changes: Record<string, unknown>, path: string = defaultHostRegistryPath()): UpdateHostResult {
const loaded = loadExistingForWrite(path);
if (!loaded.ok) return loaded;
const idx = loaded.existing.findIndex(h => h.id === id);
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
const previous = loaded.existing[idx]!;
if (changes["id"] !== undefined && changes["id"] !== id) {
return { ok: false, error: `an edit keeps the host's id fixed — '${id}' cannot become '${String(changes["id"])}'` };
}
if (changes["transport"] !== undefined) {
return { ok: false, error: `an edit keeps the host's transport fixed — '${id}' stays '${previous.transport}'` };
}
const allowed = EDITABLE_FIELDS[previous.transport];
const effective: Record<string, unknown> = {};
for (const [key, value] of Object.entries(changes)) {
if (key === "id") continue; // an id echo naming the same host is a no-op
if (!allowed.includes(key)) {
return { ok: false, error: `field '${key}' is not editable on an ${previous.transport} host (editable fields: ${allowed.join(", ")})` };
}
effective[key] = value;
}
if (Object.keys(effective).length === 0) {
return { ok: false, error: "no changes given" };
}
const updated = { ...previous } as Record<string, unknown>;
for (const [key, value] of Object.entries(effective)) {
if (CLEARABLE_FIELDS.has(key) && value === "") delete updated[key];
else updated[key] = value;
}
const candidate = loaded.existing.map((h, i) => (i === idx ? updated as unknown as HostEntry : h));
const validated = validateHostRegistry({ hosts: candidate }, path);
if (!validated.ok) return { ok: false, error: validated.error };
const entry = validated.registry.hosts.find(h => h.id === id);
if (!entry) return { ok: false, error: `host '${id}' did not survive validation` };
const writeError = writeRegistryFile(path, validated.registry.hosts);
if (writeError) return { ok: false, error: writeError };
return { ok: true, path, entry, previous };
}
/** Remove ONE entry — only the registry entry; nothing else is touched. */
export function removeHostEntry(id: string, path: string = defaultHostRegistryPath()): RemoveHostResult {
const loaded = loadExistingForWrite(path);
if (!loaded.ok) return loaded;
const idx = loaded.existing.findIndex(h => h.id === id);
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
const entry = loaded.existing[idx]!;
const remaining = loaded.existing.filter((_, i) => i !== idx);
const validated = validateHostRegistry({ hosts: remaining }, path);
if (!validated.ok) return { ok: false, error: validated.error };
const writeError = writeRegistryFile(path, validated.registry.hosts);
if (writeError) return { ok: false, error: writeError };
return { ok: true, path, entry };
}
+3
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { environmentCommand, type EnvironmentCommandDeps } from "./commands/environment.js";
import { Command } from "commander";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -143,6 +144,7 @@ export interface ProgramDeps {
skillDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
communityHarnessDeps?: StatusDeps;
environmentDeps?: EnvironmentCommandDeps;
forkDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
@@ -227,6 +229,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(skillCommand(depsOverride?.skillDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(communityHarnessCommand(depsOverride?.communityHarnessDeps));
program.addCommand(environmentCommand(depsOverride?.environmentDeps));
program.addCommand(forkCommand(depsOverride?.forkDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(rigModeCommand(depsOverride?.rigModeDeps));
+1
View File
@@ -65,6 +65,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
["skill", async () => (await import("./commands/skill.js")).skillCommand()],
["agent-image", async () => (await import("./commands/agent-image.js")).agentImageCommand()],
["community-harness", async () => (await import("./commands/community-harness.js")).communityHarnessCommand()],
["environment", async () => (await import("./commands/environment.js")).environmentCommand()],
["fork", async () => (await import("./commands/fork.js")).forkCommand()],
["workspace", async () => (await import("./commands/workspace.js")).workspaceCommand()],
["mode", async () => (await import("./commands/rig-mode.js")).rigModeCommand()],
@@ -0,0 +1,7 @@
/** An older daemon can ignore new fields. A read-only plan must acknowledge the exact selection before a mutation. */
export function accountHomePlanMatches(plan: Record<string, unknown>, home: string | null,
token?: { label: string; tokenFile: string }): boolean {
if (plan["ok"] !== true || plan["dryRun"] !== true || plan["accountHome"] !== home) return false;
const selected = plan["accountToken"] as { label?: unknown; tokenFile?: unknown } | null | undefined;
return token ? selected?.label === token.label && selected?.tokenFile === token.tokenFile : selected == null;
}
+36 -7
View File
@@ -62,6 +62,31 @@ function bodyStartIndex(lines: string[]): number {
return 0; // unterminated frontmatter — treat as no frontmatter
}
/** Keep source indices while hiding code examples from the progress grammar. */
function progressLines(lines: string[]): string[] {
let fence: { marker: string; length: number } | null = null;
const start = bodyStartIndex(lines);
return lines.map((line, i) => {
if (i < start) return "";
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (match) {
const marker = match[1]![0]!;
const length = match[1]!.length;
const tail = match[2]!;
if (fence) {
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
fence = null;
return line; // A new row may be inserted after the closing fence.
}
} else if (marker !== "`" || !tail.includes("`")) {
fence = { marker, length };
}
return "";
}
return fence ? "" : line;
});
}
export interface ProgressEditResult {
content: string;
changed: boolean;
@@ -92,32 +117,35 @@ export function addProgressRow(
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
// Locate the target section heading.
let sectionIdx = -1;
for (let i = start; i < lines.length; i++) {
const h = lines[i]!.match(HEADING_RE);
const h = visible[i]!.match(HEADING_RE);
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
}
if (sectionIdx === -1) {
// Create the section, appended after the last existing content.
let end = lines.length;
while (end > start && lines[end - 1]!.trim() === "") end--;
while (end > start && visible[end - 1]!.trim() === "") end--;
const head = lines.slice(0, end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
const suffix = lines.slice(end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
return { content: rebuilt, changed: true };
}
// Section block = [sectionIdx+1, nextHeadingOrEOF).
let blockEnd = lines.length;
for (let i = sectionIdx + 1; i < lines.length; i++) {
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
}
// Idempotency / conflict scan within the section.
for (let i = sectionIdx + 1; i < blockEnd; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) {
const current = indicatorToStatus(cb[2]!);
if (current === opts.status) return { content, changed: false };
@@ -132,7 +160,7 @@ export function addProgressRow(
// Insert after the last non-blank line in the section block.
let insertAt = sectionIdx + 1;
for (let i = sectionIdx + 1; i < blockEnd; i++) {
if (lines[i]!.trim() !== "") insertAt = i + 1;
if (visible[i]!.trim() !== "") insertAt = i + 1;
}
lines.splice(insertAt, 0, newRow);
return { content: lines.join("\n"), changed: true };
@@ -155,10 +183,11 @@ export function setProgressRow(
const text = opts.text.trim();
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
const matches: number[] = [];
for (let i = start; i < lines.length; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) matches.push(i);
}
+85 -18
View File
@@ -18,6 +18,15 @@ const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
/** The most text rig_context_get and rig_artifact_read return in one call; a longer file or section is cut, and says so. */
const CONTEXT_TEXT_LIMIT = 48_000;
/** Where a cut of text at end goes: one unit earlier when end falls inside a surrogate pair (an emoji, say), so the
* cut text and the rest after it both hold whole characters. */
function wholeCharEnd(text: string, end: number): number {
if (end <= 0 || end >= text.length) return end;
const before = text.charCodeAt(end - 1);
const after = text.charCodeAt(end);
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff ? end - 1 : end;
}
type TextResult = {
content: Array<{ type: "text"; text: string }>;
isError?: true;
@@ -327,10 +336,11 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
};
}
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
* task to one. The shape never grants anything. */
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
* anything, and a grant with team/list doesn't read it. */
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
@@ -340,11 +350,15 @@ const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
* its named roots. */
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
const rigs = new Set(access.rigs);
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
const participant = access.external?.objects.team?.includes("list") === true;
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
const member = (value: unknown): boolean => {
@@ -352,17 +366,20 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
const parts = value.split("@");
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
};
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
return {
session: (value) => caller(value) || team(value),
item,
owner: () => false,
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
// the claimant.
owner: (row) => caller(field(row, "destinationSession")) && item(row),
rig: (name) => typeof name === "string" && rigs.has(name),
root: (name) => typeof name === "string" && access.roots.includes(name),
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
// caller-shaped seat, which v1 can't tell apart.
destination: team,
successor: caller,
artifact: (row) => caller(field(row, "sourceSession")),
@@ -399,7 +416,7 @@ function artifactView(source: string, read: FileRead, offset: number): Record<st
const base = { available: true, source, root: read.root, path: read.path, size: read.size, mtime: read.mtime };
if (read.binary) return { ...base, binary: true, note: "A binary file: not shown as text." };
const text = read.content ?? "";
const end = offset + CONTEXT_TEXT_LIMIT;
const end = wholeCharEnd(text, offset + CONTEXT_TEXT_LIMIT);
return {
...base, binary: false, offset, text: text.slice(offset, end), totalChars: text.length,
truncated: text.length > end || read.truncated === true,
@@ -471,7 +488,8 @@ export type McpAccess = {
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
team: { list: "rig_team" },
artifact: { read: "rig_artifact_read" },
};
@@ -514,6 +532,7 @@ export const MCP_OPERATIONS: ReadonlyArray<{ tool: string; routes: readonly stri
{ tool: "rig_queue_list", routes: ["GET /api/queue/list"], effect: "read", access: "work" },
{ tool: "rig_queue_claim", routes: ["POST /api/queue/:qitemId/claim"], effect: "destructive", access: "work" },
{ tool: "rig_queue_update", routes: ["POST /api/queue/:qitemId/update"], effect: "destructive", access: "work" },
{ tool: "rig_environment_export", routes: ["GET /api/queue/:qitemId", "POST /api/environments/:bindingId/export"], effect: "additive", access: "work" },
{ tool: "rig_telemetry_events", routes: ["GET /api/telemetry/v1/events"], effect: "read", access: "owner" },
{ tool: "rig_whoami", routes: ["GET /api/whoami"], effect: "read", access: "work" },
{ tool: "rig_team", routes: ["GET /api/rigs/:rigId", "GET /api/rigs/:rigId/nodes", "GET /healthz"], effect: "read", access: "work" },
@@ -554,6 +573,16 @@ const SHARED_INSTRUCTIONS = [
];
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, 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); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined): string {
if (!access?.external) return INSTRUCTIONS;
const tools = externalTools(access.external.objects);
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
}
/**
* Maps a DaemonResponse to MCP tool result.
@@ -606,7 +635,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const server = new McpServer({
name: "openrig",
version: CLI_VERSION,
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
}, { instructions: instructionsFor(options.access) });
const { access } = options;
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
@@ -1335,7 +1364,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId);
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
// An external caller's claimed item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1366,15 +1398,46 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId, "owner");
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
if (access?.external && (blockedOn || state === "blocked")) {
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
}
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
}), undefined, { structured: true });
});
// An external caller's updated item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
},
);
// Artifact publication uses this actual authenticated work caller, never a body actor or activity token.
if (offered("rig_environment_export")) server.registerTool(
"rig_environment_export",
{
description: "Transfer a bound worker's actual JSON artifact to the host, verify its bytes/schema/context and append a durable receipt to its existing work. Does not close work, stop compute or restore native history. Requires an authenticated HTTP work connection.",
inputSchema: z.object({
bindingId: z.string(), operationId: z.string(), taskId: z.string(), generation: z.string(), path: z.string(),
sha256: z.string(), bytes: z.number().int().positive().max(1048576),
schema: z.object({ format: z.literal("json-object/v1"), fields: z.record(z.string(), z.enum(["string", "number", "boolean"])), required: z.array(z.string()) }),
context: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
}),
annotations: ADDITIVE,
},
async ({ bindingId, ...input }) => {
if (!access) return { content: [{ type: "text" as const, text: "This export requires a validated HTTP work grant; a declared stdio/local identity is not authentication." }], isError: true };
try {
const denied = await itemOutOfScope(input.taskId, "owner"); if (denied) return denied;
return mapResult(await client.post(`/api/environments/${encodeURIComponent(bindingId)}/export`, input), undefined, { structured: true });
} catch (error) { return caught(error); }
},
);
// 24. rig_telemetry_events — one bounded page of event metadata, the page `rig telemetry events` reads
if (offered("rig_telemetry_events")) server.registerTool(
"rig_telemetry_events",
@@ -1459,6 +1522,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
},
async ({ rigId }) => {
try {
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
const rig = await rigIdFor(client, rigId);
if ("content" in rig) return rig;
const id = encodeURIComponent(rig.id);
@@ -1479,7 +1544,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
async (seat) => {
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
.then((res) => (res.status < 400 ? res.data : null), () => null);
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
});
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
@@ -1581,7 +1648,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
return mapResult({ status: 200, data: {
address: found.address ?? address, packRef: found.packRef, filePath: found.filePath,
...(found.headerPath !== undefined ? { headerPath: found.headerPath } : {}),
text: cut ? text.slice(0, CONTEXT_TEXT_LIMIT) : text,
text: cut ? text.slice(0, wholeCharEnd(text, CONTEXT_TEXT_LIMIT)) : text,
...(cut ? { truncated: true, totalChars: text.length } : {}),
} }, undefined, { structured: true });
} catch (err) {
+2 -2
View File
@@ -36,8 +36,8 @@ export function isFixtureScopedHome(home: string): boolean {
return underTemp && /(^|\/)openrig-qa[^/]*/.test(home);
}
export function getOpenRigHome(): string {
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
export function getOpenRigHome(env: NodeJS.ProcessEnv = process.env): string {
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME", env);
if (configured !== undefined) return configured;
return join(homedir(), ".openrig");
}
+9 -13
View File
@@ -1,13 +1,14 @@
import { spawn } from "node:child_process";
import { DaemonClient } from "./client.js";
import { assertLocalTmux, attachTmux, readTmuxServerArgs, type TmuxAttachment } from "./tmux-attachment.js";
interface SharedTuiClient {
readonly baseUrl?: string;
isLocalInstance(): Promise<boolean>;
get<T>(path: string): Promise<{ status: number; data: T }>;
}
/** Resolve the terminal on the selected instance, not a remembered tmux name. */
export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient()): Promise<string> {
export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient()): Promise<TmuxAttachment> {
if (client.baseUrl && !["localhost", "127.0.0.1", "[::1]"].includes(new URL(client.baseUrl).hostname)) {
throw new Error("Shared attachment uses local tmux. Run rig tui --shared on the selected daemon's machine; standalone rig tui can use this remote connection.");
}
@@ -20,23 +21,18 @@ export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient
const nodes = await client.get<Array<{
logicalId: string; runtime: string | null;
canonicalSessionName: string | null; tmuxAttachCommand: string | null;
tmuxServerArgs?: string[];
tmuxServerMode?: "inherit" | "isolated";
}>>(`/api/rigs/${encodeURIComponent(kernel.rigId)}/nodes`);
if (nodes.status >= 400) throw new Error(`Cannot read kernel terminals (HTTP ${nodes.status}); run rig status.`);
const terminal = nodes.data.find((n) => n.logicalId === "operator.human" && n.runtime === "terminal");
if (!terminal?.canonicalSessionName || !terminal.tmuxAttachCommand) {
throw new Error("The kernel has no bound shared terminal. Inspect rig ps --nodes --rig kernel; restore the existing terminal rather than launching another kernel. Standalone: rig tui.");
}
return terminal.canonicalSessionName;
await assertLocalTmux(client, terminal.tmuxServerMode, "Shared attachment uses local tmux. The selected daemon's instance identity could not be confirmed locally; run rig tui --shared on that daemon's machine. Standalone rig tui can use this remote connection.");
return { session: terminal.canonicalSessionName, serverArgs: readTmuxServerArgs(terminal.tmuxServerArgs) };
}
export async function attachSharedTui(target: string): Promise<number> {
// Attach a new client even inside tmux. switch-client would retarget somebody
// else's client when invoked from an agent's child shell.
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.TMUX;
const child = spawn("tmux", ["attach-session", "-t", `=${target}`], { stdio: "inherit", env });
child.once("error", reject);
child.once("exit", (code) => resolve(code ?? 1));
});
export async function attachSharedTui(target: TmuxAttachment): Promise<number> {
return attachTmux(target);
}
+36 -31
View File
@@ -1,12 +1,13 @@
import { execFile, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir, hostname, networkInterfaces } from "node:os";
import { homedir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
import { assertLocalTmux, isLocalTmuxHost, readTmuxServerArgs } from "./tmux-attachment.js";
import { openRexView } from "./terminal-window-rex.js";
import type { FollowControls, OpenViewResult } from "./commands/terminal.js";
@@ -15,8 +16,9 @@ interface HerdrTab { workspace_id: string; tab_id: string; label: string }
interface Preview {
planId: string;
session?: string;
tmux?: { mode: "inherit" | "isolated" };
status: { launch?: { socketPath: string; session?: string } };
composed: { id: string; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
composed: { id: string; localTmuxServer?: readonly string[]; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
}
export interface WindowDeps {
@@ -82,13 +84,6 @@ export function defaultWindowDeps(): WindowDeps {
};
}
function localDaemon(url: string): boolean {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
return ["localhost", "::1", "::", "0.0.0.0", hostname()].includes(host)
|| /^127\./.test(host)
|| Object.values(networkInterfaces()).flat().some(iface => iface?.address === host);
}
/** A tmux started inside a Herdr pane sets TERM_PROGRAM=tmux but inherits Herdr's pane markers. */
function inHerdrPane(env: NodeJS.ProcessEnv): boolean {
return env["HERDR_ENV"] === "1" && !!env["HERDR_PANE_ID"] && !!env["HERDR_SOCKET_PATH"];
@@ -210,7 +205,7 @@ function failure(provider: string, error: string): OpenViewResult {
}
/** Saved Herdr labels survive reboot; only current clients prove a local attachment. */
async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: Pane[][], pageLabel: (index: number) => string, herdrArgs: string[], deps: WindowDeps): Promise<boolean> {
async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: Pane[][], pageLabel: (index: number) => string, herdrArgs: string[], deps: WindowDeps, serverArgs: readonly string[]): Promise<boolean> {
try {
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"])).trim();
if (!path.isAbsolute(tmux)) return false;
@@ -220,14 +215,15 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
const clients = new Map<string, string[]>();
// Match view-composer's serialization (these strings are compared, never executed).
const quote = (value: string) => "'" + value.replace(/'/g, "'\"'\"'") + "'";
const inventory = await deps.exec(tmux, ["list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
const server = serverArgs.map((arg, index) => index % 2 ? quote(arg) : arg).join(" ");
const inventory = await deps.exec(tmux, [...serverArgs, "list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
for (const line of inventory.trim().split("\n")) {
const [pid, session, readOnly] = line.split("\t");
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
// Compare the composer's exact command, including session aliases and read-only mode.
// SSH/custom commands cannot be confirmed from this local client inventory.
const flags = readOnly === "1" ? ["-r "] : ["", "-f ignore-size "];
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary} attach ${flag}-t ${quote(session)}`)));
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary}${server ? ` ${server}` : ""} attach ${flag}-t ${quote(session)}`)));
}
const used = new Set<string>();
for (const [index, page] of pages.entries()) {
@@ -282,7 +278,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
: `No terminal window was opened. ${reason} On the daemon's desktop, run: ${recovery}`), windowAttempted, ...(session !== undefined ? { session } : {}) });
const windowNotes: string[] = [];
try {
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (!isLocalTmuxHost(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (following && selectedProvider !== "herdr") throw new Error("Following requires the qualified Herdr provider.");
if (session !== undefined && selectedProvider !== "herdr") throw new Error("--session selects a Herdr session; use --provider herdr.");
if (selectedProvider && !["herdr", "tmux"].includes(selectedProvider)) throw new Error("--window supports herdr, rex or tmux. Use cmux without --window.");
@@ -305,6 +301,8 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
const launchTarget = preview.data.status.launch;
let { composed, planId } = preview.data;
await assertLocalTmux(client, preview.data.tmux?.mode, "The window launcher must run on the daemon's own desktop; its instance identity could not be confirmed locally.");
const tmuxServer = readTmuxServerArgs(composed.localTmuxServer);
if (expectedPlan !== undefined && expectedPlan !== planId) {
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
}
@@ -314,10 +312,14 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
viewportColumns = window.columns;
const next = await client.get<Preview | OpenViewResult>(previewUrl());
if (next.status >= 400 || !("composed" in next.data)) throw new Error("Could not compose the measured terminal view.");
await assertLocalTmux(client, next.data.tmux?.mode, "The window launcher must run on the daemon's own desktop; its instance identity could not be confirmed locally.");
if (session !== undefined && (next.data.session !== session || next.data.status.launch?.socketPath !== launchTarget?.socketPath)) {
throw new Error("The selected Herdr endpoint changed while opening the window. Inspect it before retrying.");
}
if (expectedPlan !== undefined && expectedPlan !== next.data.planId) throw new Error("The measured terminal layout differs from the expected preview; refresh the preview before opening.");
if (JSON.stringify(readTmuxServerArgs(next.data.composed.localTmuxServer)) !== JSON.stringify(tmuxServer)) {
throw new Error("The daemon's tmux server changed while opening the window; refresh the preview before retrying.");
}
({ composed, planId } = next.data);
if (!composed.opened.length) throw new Error("No conversations are attachable after opening the terminal.");
}
@@ -360,9 +362,9 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
const directCommand = herdr && socket
? `${configPrefix}env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(socket)}${configEnv} ${shellQuote(herdr)}`
: `env -u TMUX ${composed.opened[0]!.paneCommand.replace(/^ssh /, "ssh -t ")}`;
: `env -u TMUX ${composed.opened[0]!.paneCommand.replace(/^ssh (?!-t(?:\s|$))/, "ssh -t ")}`;
manualCommand = herdr && socket ? directCommand
: `env -u TMUX ${(composed.opened[0]!.recoveryCommand ?? composed.opened[0]!.paneCommand).replace(/^ssh /, "ssh -t ")}`;
: `env -u TMUX ${(composed.opened[0]!.recoveryCommand ?? composed.opened[0]!.paneCommand).replace(/^ssh (?!-t(?:\s|$))/, "ssh -t ")}`;
if (typeof launchWindow === "string") {
const headless = deps.env["SSH_CONNECTION"] || deps.env["SSH_CLIENT"] || deps.env["SSH_TTY"]
|| (deps.platform === "linux" && !deps.env["DISPLAY"] && !deps.env["WAYLAND_DISPLAY"]);
@@ -467,7 +469,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
let existing: HerdrTab | undefined;
for (const candidate of candidates) {
if (await hasLiveAttachments(candidate, tabs, composed.pages, pageLabel(candidate), herdrArgs, deps)) { existing = candidate; break; }
if (await hasLiveAttachments(candidate, tabs, composed.pages, pageLabel(candidate), herdrArgs, deps, tmuxServer)) { existing = candidate; break; }
windowNotes.push(`Could not confirm live attachments in workspace ${candidate.workspace_id}. Its contents were kept. After inspecting it, close it if no longer needed: ${closeCommand(candidate.workspace_id)}`);
}
if (existing) {
@@ -494,14 +496,17 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
if (typeof launchWindow !== "function") throw new Error("No terminal window launcher is available.");
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"]).catch(() => "")).trim();
if (!tmux) throw new Error("tmux is unavailable; run rig setup first.");
const runTmux = (args: string[]) => deps.exec(tmux, [...tmuxServer, ...args]);
const serverWords = tmuxServer.map((arg, index) => index % 2 ? shellQuote(arg) : arg).join(" ");
const tmuxCommand = `${shellQuote(tmux)}${serverWords ? ` ${serverWords}` : ""}`;
viewer = `openrig-view-${deps.id()}`;
let placeholder: string | undefined;
if (composed.kernelLayout) {
// This is our fresh, empty viewer, never a source seat. Attach it first so Terminal can
// report the real new window width before we select the OpenRig view layout.
placeholder = (await deps.exec(tmux, ["new-session", "-d", "-s", viewer, "-n", "view-1", "-P", "-F", "#{pane_id}"])).trim();
placeholder = (await runTmux(["new-session", "-d", "-s", viewer, "-n", "view-1", "-P", "-F", "#{pane_id}"])).trim();
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
window = await launchWindow(`env -u TMUX ${tmuxCommand} attach-session -t ${shellQuote(`=${viewer}`)}`);
await measureWindow();
}
for (const [pageIndex, page] of composed.pages.entries()) {
@@ -511,27 +516,27 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
const args = pageIndex === 0 ? ["new-session", "-d", "-s", viewer, "-n", name] : ["new-window", "-d", "-t", `${viewer}:`, "-n", name];
let pane: string;
if (pageIndex === 0 && placeholder) {
await deps.exec(tmux, ["respawn-pane", "-k", "-t", placeholder, `env -u TMUX ${first.paneCommand}`]);
await runTmux(["respawn-pane", "-k", "-t", placeholder, `env -u TMUX ${first.paneCommand}`]);
pane = placeholder;
} else pane = (await deps.exec(tmux, [...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
} else pane = (await runTmux([...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
// Nested source clients on this server can make a new window too narrow to split.
// Use the viewer's configured detached size, then restore normal client resizing.
const [width, height] = (await deps.exec(tmux, ["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
const sizing = (await deps.exec(tmux, ["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await deps.exec(tmux, ["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", first.label]);
const [width, height] = (await runTmux(["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
const sizing = (await runTmux(["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
for (const item of page.slice(1)) {
pane = (await deps.exec(tmux, ["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", item.label]);
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
}
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await deps.exec(tmux, ["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await runTmux(["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
}
await deps.exec(tmux, ["select-window", "-t", `${viewer}:view-1`]);
await runTmux(["select-window", "-t", `${viewer}:view-1`]);
if (!window) {
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
window = await launchWindow(`env -u TMUX ${tmuxCommand} attach-session -t ${shellQuote(`=${viewer}`)}`);
}
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), absent: composed.absent, degraded: composed.degraded, pages: composed.pages.length, window, notes: [`Viewing session: ${viewer}. Existing conversations were preserved.`, ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
} catch (err) {
+94
View File
@@ -0,0 +1,94 @@
import { spawn } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { hostname, networkInterfaces } from "node:os";
import path from "node:path";
import type { DaemonClient } from "./client.js";
export interface TmuxAttachment {
session: string;
serverArgs: string[];
readOnly?: boolean;
}
/** The daemon supplies server options as data; clients never parse a shell command. */
export function readTmuxServerArgs(value: unknown): string[] {
if (value === undefined) return []; // Compatibility with a daemon predating server metadata.
if (!Array.isArray(value) || (value.length !== 0 && (value.length !== 2
|| (value[0] !== "-S" && value[0] !== "-L")
|| typeof value[1] !== "string" || !value[1] || value[1].includes("\0")))) {
throw new Error("The daemon returned invalid tmux server options.");
}
return [...value] as string[];
}
/** Offline cleanup pins only active isolation/migration; ordinary inherit uses tmux's environment. */
export function readRecordedTmuxServerArgs(home: string): string[] {
let record: { version?: unknown; home?: unknown; mode?: unknown; socketPath?: unknown; isolatedSocketPath?: unknown; pinned?: unknown } | null = null;
let readError: unknown;
try { record = JSON.parse(readFileSync(path.join(home, "tmux-server.json"), "utf8")); }
catch (error) { readError = error; }
const pinned = record?.mode === "isolated" || record?.pinned === true;
let matchesHome = false;
try { matchesHome = record?.home === realpathSync(home); } catch { /* A missing/unreadable home is not a valid record. */ }
const valid = record?.version === 1 && matchesHome
&& (record.mode === "inherit" || record.mode === "isolated")
&& (record.pinned === undefined || typeof record.pinned === "boolean")
&& !(record.mode === "isolated" && record.pinned === false)
&& typeof record.socketPath === "string" && path.isAbsolute(record.socketPath) && !record.socketPath.includes("\0")
&& (record.isolatedSocketPath === undefined || (typeof record.isolatedSocketPath === "string"
&& path.isAbsolute(record.isolatedSocketPath) && !record.isolatedSocketPath.includes("\0")));
if (valid) {
// An explicit opt-out record is committed before the marker is removed.
// Older inherit records are advisory too, never a remembered cleanup socket.
return pinned ? readTmuxServerArgs(["-S", record!.socketPath]) : [];
}
let marked = false;
if (!pinned) {
try { marked = readFileSync(path.join(home, "tmux-server.initialized"), "utf8").trim() === "openrig-tmux-pinned-v1"; }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error(`Cannot read the tmux server pin marker: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!marked) return []; // No active pin: missing/bad advisory records cannot change inheritance.
}
if (readError) {
if ((readError as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error("Missing tmux server record after pinned server selection; restore it before destroying this instance's sessions.");
}
throw new Error(`Cannot read the tmux server record: ${readError instanceof Error ? readError.message : String(readError)}`);
}
throw new Error("Invalid tmux server record; inspect it before destroying this instance's sessions.");
}
/** Host locality keeps the existing window-launcher behaviour in inherit mode. */
export function isLocalTmuxHost(url: string): boolean {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
return ["localhost", "::1", "::", "0.0.0.0", hostname()].includes(host)
|| /^127\./.test(host)
|| Object.values(networkInterfaces()).flat().some(iface => iface?.address === host);
}
/** Only an effective isolated server needs proof beyond the legacy host check. */
export async function assertLocalTmux(
client: Pick<DaemonClient, "isLocalInstance">,
mode: "inherit" | "isolated" | undefined,
message = "Terminal attachment uses local tmux. The selected daemon's instance identity could not be confirmed locally; run this command on that daemon's machine.",
): Promise<void> {
if (mode === "isolated" && !await client.isLocalInstance()) throw new Error(message);
}
/** Attach only; creating or retargeting somebody else's tmux client is not part of this operation. */
export function attachTmux(target: TmuxAttachment): Promise<number> {
const server = readTmuxServerArgs(target.serverArgs);
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.TMUX;
delete env.TMUX_PANE;
const child = spawn("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`],
{ stdio: "inherit", env, windowsHide: true });
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
});
}
+20
View File
@@ -122,6 +122,26 @@ describe("rig acp run", () => {
expect(q.task().notes.join("\n")).toMatch(/cancel requested at \S+; upstream inference and spend are unknown/);
});
it("keeps waiting for cancellation when recording the cancel request fails", async () => {
const q = memoryQueue("scenario:slow");
const update = q.queue.update;
q.queue.update = async (id, change) => {
if (/^ACP run \S+: cancel requested at/.test(change.note)) throw new Error("queue unavailable");
return update(id, change);
};
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
let cancel!: () => void;
const cancelRequested = new Promise<void>((resolve) => { cancel = resolve; });
const outcome = await runAcpAssignment(q.queue, options({
cancelRequested,
progress: (line) => { if (line.startsWith("step ")) cancel(); },
}));
expect(outcome.kind).toBe("cancelled");
expect(errors).toHaveBeenCalledWith(expect.stringContaining("queue unavailable"));
expect(outcomeNote(q)).toContain("outcome cancelled");
errors.mockRestore();
});
it("closes an unanswered permission row as stale when the turn is cancelled, and tells the agent cancelled", async () => {
const q = memoryQueue("scenario:permission");
let cancel!: () => void;
+18 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { execFileSync, spawn } from "node:child_process";
import http from "node:http";
import { Command } from "commander";
import { writeTextAtomically } from "../src/atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
import { exportCommand } from "../src/commands/export.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
@@ -35,6 +35,23 @@ it("preserves an existing export and removes staging after a partial failed writ
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
});
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
writeNewText(target, "name: first\n");
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
fs.symlinkSync(join(dir, "missing.yaml"), link);
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
});
it("writeNewText leaves nothing behind after a failed write", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
failure.active = true;
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
expect(fs.readdirSync(dir)).toEqual([]);
});
it("retains export symlinks and target permissions on successful replacement", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
+10 -3
View File
@@ -181,6 +181,13 @@ describe("Attach CLI", () => {
});
});
it("includes the actual socket without trimming literal path whitespace", () => {
const result = withTmuxPane("%42", () => resolveAttachContext(fakeTmux({
pane: "%42", window: "@1", session: "team_1 rig_b @7 %9", socket: "/private/socket name ",
})));
expect(result).toMatchObject({ attachmentType: "tmux", tmuxSocketPath: "/private/socket name ", tmuxSession: "team_1 rig_b @7 %9" });
});
// Real tmux rewrote the newlines of a combined format as "_" ("unrelated-shell_@2_%2"), and the session fell back to
// external_cli, so replies to the self-attached member had no transport.
it.each([
@@ -216,18 +223,18 @@ describe.runIf(tmuxAvailable)("resolveAttachContext against a real tmux server (
const window = read("#{window_id}");
const onSocket = (cmd: string) => execSync(cmd.replace(/^tmux /, `tmux -L ${TMUX_SOCKET} `), { encoding: "utf8" }).trim();
expect(withTmuxPane(pane, () => resolveAttachContext(onSocket)))
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane });
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane, tmuxSocketPath: read("#{socket_path}") });
});
});
/** A `tmux display-message -p` double that renders the requested format as real tmux does, control characters
* included: tmux writes each one as "_". */
function fakeTmux(values: { session: string; window: string; pane: string }) {
function fakeTmux(values: { session: string; window: string; pane: string; socket?: string }) {
return vi.fn((cmd: string) => {
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1];
if (format === undefined) throw new Error(`unexpected tmux command: ${cmd}`);
return format.replace(/#\{session_name\}/g, values.session).replace(/#\{window_id\}/g, values.window)
.replace(/#\{pane_id\}/g, values.pane).replace(/[\u0000-\u001f]/g, "_");
.replace(/#\{pane_id\}/g, values.pane).replace(/#\{socket_path\}/g, values.socket ?? "").replace(/[\u0000-\u001f]/g, "_");
});
}
+1
View File
@@ -326,6 +326,7 @@ describe("Broadcast CLI", () => {
const output = logs.join("\n");
expect(output).toContain("No running sessions found");
expect(output).toContain("0/0 delivered");
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
expect(exitCode).toBe(1);
});
});
@@ -41,7 +41,7 @@ describe("chatroom watch --tmux arguments", () => {
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "-rig",
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
@@ -72,14 +72,18 @@ describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
});
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
// prints the name tmux reports, which is always the session tmux has.
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
const out = path.join(dir, "argv-dot.json");
const reported = start("release-0.7", out);
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
// tmux versions differ in how they normalize dots and literal dollars in session names.
// The reported name must address the real session, while the watcher still gets the original rig name.
it.each([
["release-0.7", ["[email protected]", "chatroom@release-0_7"]],
["a$HOME", ["chatroom@a$HOME", "chatroom@a\\$HOME"]],
] as const)("reports the name tmux used for %s", async (rig, expected) => {
const out = path.join(dir, `argv-normalized-${rig}.json`);
const reported = start(rig, out);
expect(expected).toContain(reported);
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
.toContain(reported);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
expect(spawnSync("tmux", ["-L", TMUX_SOCKET, "has-session", "-t", `=${reported}:`]).status).toBe(0);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
});
});
@@ -44,7 +44,7 @@ function clearEnv(): () => void {
// its custom home into default-path resolution; UNSETTING it would fall through
// to the REAL ~/.openrig (foreign on-box state) — so tests set a fresh tmp home.
"OPENRIG_HOME", "RIGGED_HOME",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
@@ -109,6 +109,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
restoreEnv();
});
it("resolves tmux isolation from the shared config and environment with a strict mode", () => {
const store = new ConfigStore(configPath);
expect(store.resolve().terminal.tmuxServer).toBe("inherit");
store.set("terminal.tmux_server", "isolated");
expect(store.resolve().terminal.tmuxServer).toBe("isolated");
process.env.OPENRIG_TMUX_SERVER = "inherit";
expect(store.resolve().terminal.tmuxServer).toBe("inherit");
expect(() => store.set("terminal.tmux_server", "isolate")).toThrow('must be "inherit" or "isolated"');
});
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "daemon.db_conflict",
@@ -174,6 +184,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"snapshots.periodic.retention_keep",
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default (static bool).
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
@@ -67,6 +67,9 @@ describe("cross-host target resolution — alias -> id -> transport", () => {
expect(r.target).toBe("pm@some-rig@host-84c37990");
expect(r.sugarHost).toBeUndefined();
expect(r.hint).toContain("no registered host 'host-84c37990'");
// The step that binds a peer's self-id, not only the list of hosts.
expect(r.hint).toContain("run `rig host doctor <alias>` for that machine's entry if it uses http (it learns the id)");
expect(r.hint).toContain("set `hostId: host-84c37990` on that entry in hosts.yaml (any transport)");
});
// Two spellings of ONE host. An operator who pastes back the reply hint we printed them AND
+149
View File
@@ -18,6 +18,7 @@ import {
ensureWorkspaceScaffold,
daemonNotRunningError,
printDaemonNotRunning,
daemonStatusGuard,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -1202,6 +1203,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces native module runtime mismatch inline when healthz never comes up", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -1232,6 +1234,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces the recent daemon log line when startup crashes for another reason", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -1916,3 +1919,149 @@ describe("daemonNotRunningError / printDaemonNotRunning (AC-4 shared honest erro
}
});
});
// ===================================================================
// T2 — `rig daemon status` during a pending start. A start reserves the home with
// daemon-start.lock (JSON lines: {launcherPid, startedAt}, then {childPid}) before it
// spawns, and the listener is not up yet. A refused probe under a LIVE reservation is a
// launch in progress, not "stopped". Reservations with only dead PIDs, no reservation,
// or an unreadable reservation keep the stopped verdict.
// Fixture home: a real temp dir; exists/readFile reach the disk only under that dir, so
// the developer's own ~/.openrig state is never read.
// ===================================================================
describe("T2 — start-pending status (daemon-start.lock)", () => {
const LAUNCHER_PID = 4242;
const CHILD_PID = 5151;
const STARTED_AT = "2026-11-01T00:00:00.000Z";
let home: string;
let savedUrl: string | undefined;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "t2-start-pending-"));
savedUrl = process.env["OPENRIG_URL"];
delete process.env["OPENRIG_URL"]; // force the no-state config branch
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
if (savedUrl !== undefined) process.env["OPENRIG_URL"] = savedUrl;
});
function pendingDeps(opts: { lock?: string; alive: number[]; liveness?: "unknown"; refusal?: Error }): LifecycleDeps {
const underHome = (p: string) => p.startsWith(home + path.sep) && fs.existsSync(p);
if (opts.lock !== undefined) fs.writeFileSync(path.join(home, "daemon-start.lock"), opts.lock);
return mockDeps({
homeDir: home,
exists: vi.fn((p: string) => underHome(p)),
readFile: vi.fn((p: string) => (underHome(p) ? fs.readFileSync(p, "utf8") : null)),
isProcessAlive: vi.fn((pid: number) => opts.alive.includes(pid)),
processLiveness: opts.liveness
? vi.fn((pid: number) => (opts.alive.includes(pid) ? "unknown" : "dead"))
: undefined,
sleep: async () => {},
fetch: vi.fn(async () => { throw opts.refusal ?? new Error("ECONNREFUSED"); }), // listener refused
});
}
it("T2 RED: refused listener + live launcher reservation -> unverified with a start-pending record (not stopped)", async () => {
const deps = pendingDeps({ lock: `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n`, alive: [LAUNCHER_PID] });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("unverified"); // <-- RED: currently "stopped"
expect(status.startPending).toEqual({ pid: LAUNCHER_PID, since: STARTED_AT });
});
it("T2 RED: launcher dead but recorded child alive -> still start-pending, naming the live child", async () => {
const lock = `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n{"childPid":${CHILD_PID}}\n`;
const deps = pendingDeps({ lock, alive: [CHILD_PID] });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("unverified"); // <-- RED: currently "stopped"
expect(status.startPending).toEqual({ pid: CHILD_PID, since: STARTED_AT });
});
it("T2 RED: a live reservation pid whose liveness is 'unknown' (cannot signal) still counts as pending", async () => {
const deps = pendingDeps({ lock: `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n`, alive: [LAUNCHER_PID], liveness: "unknown" });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("unverified"); // <-- RED: currently "stopped"
expect(status.startPending?.pid).toBe(LAUNCHER_PID);
});
it("T2 RED (CLI): `rig daemon status` prints the start-pending line during a refused pending start", async () => {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const deps = pendingDeps({ lock: `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n`, alive: [LAUNCHER_PID] });
const program = new Command();
program.addCommand(daemonCommand(deps));
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "status"]);
} finally {
console.log = origLog;
}
expect(logs).toContain(`Daemon starting: launch reserved by pid ${LAUNCHER_PID} since ${STARTED_AT}; not listening yet`); // <-- RED: no such line
expect(logs.join("\n")).not.toContain("Daemon stopped");
});
it("T2 preserve (GREEN): refused listener + reservation whose pids are all dead -> stopped, unchanged", async () => {
const deps = pendingDeps({ lock: `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n{"childPid":${CHILD_PID}}\n`, alive: [] });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("stopped");
expect(status.startPending).toBeUndefined();
});
it("T2 preserve (GREEN): refused listener + no reservation file -> stopped, unchanged", async () => {
const deps = pendingDeps({ alive: [LAUNCHER_PID] });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("stopped");
expect(status.startPending).toBeUndefined();
});
it("T2 preserve (GREEN): unreadable reservation (no parseable pid) -> stopped, unchanged", async () => {
const deps = pendingDeps({ lock: "not-json\n", alive: [LAUNCHER_PID] });
const status = await getDaemonStatus(deps, {});
expect(status.state).toBe("stopped");
expect(status.startPending).toBeUndefined();
});
// A guarded command must not send its request into a refusing port while a start is pending: it stops with
// guidance, as it did when this read "stopped". Node's fetch carries the refusal as cause.code; a refusal named
// only in the message is the other shape the probe already reads as refused.
const LIVE_LOCK = `{"launcherPid":${LAUNCHER_PID},"startedAt":"${STARTED_AT}"}\n`;
const refusals: Array<[string, Error | undefined]> = [
["cause.code ECONNREFUSED", Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } })],
["a refusal named in the message", undefined],
];
it.each(refusals)("a guarded command stops with the starting guidance under a live reservation (%s)", async (_shape, refusal) => {
const status = await getDaemonStatus(pendingDeps({ lock: LIVE_LOCK, alive: [LAUNCHER_PID], refusal }), {});
if (refusal) expect(status.probeErrorCode).toBe("ECONNREFUSED");
const errors: string[] = [];
const origError = console.error;
const origExit = process.exitCode;
console.error = (...args: unknown[]) => errors.push(args.join(" "));
try {
expect(daemonStatusGuard(status)).toBe(false);
} finally {
console.error = origError;
process.exitCode = origExit;
}
expect(errors[0]).toBe(`Error: Daemon is starting (launch reserved by pid ${LAUNCHER_PID} since ${STARTED_AT}); it isn't listening yet.`);
expect(errors.join("\n")).toContain("Wait, then re-check with 'rig daemon status'.");
});
it("`rig daemon status` during a pending start doesn't call the refused probe inconclusive", async () => {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const program = new Command();
program.addCommand(daemonCommand(pendingDeps({ lock: LIVE_LOCK, alive: [LAUNCHER_PID] })));
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "status"]);
} finally {
console.log = origLog;
}
expect(logs[0]).toBe(`Daemon starting: launch reserved by pid ${LAUNCHER_PID} since ${STARTED_AT}; not listening yet`);
expect(logs.join("\n")).not.toMatch(/timed out or was inconclusive/);
});
});
@@ -4,7 +4,7 @@ import type { ChildProcess } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { startDaemon, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
afterEach(() => vi.useRealTimers());
@@ -67,6 +67,8 @@ describe("child-bound startup", () => {
? { ok: true, json: async () => ({ ...f.body, pid: 888 }) } : fetch(url);
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/identity mismatch.*127.0.0.2/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("publishes the verified child, naming the unverified listener, when only a secondary listener stays uncertain", async () => {
vi.useFakeTimers();
@@ -114,7 +116,7 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
expect(f.files.has(STATE_FILE)).toBe(false);
});
it("gives up on a never-answering endpoint within one readiness deadline", async () => {
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
vi.useFakeTimers();
const f = fixture(); const fetch = f.deps.fetch;
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
@@ -122,7 +124,11 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
expect(elapsed).toBeLessThanOrEqual(20_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(true);
expect((outcome as Error).message).toMatch(/pending.*12345/);
expect((outcome as Error).message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect((outcome as Error).message).toContain("daemon.log");
});
// Synchronous work crossing the deadline is modelled by a clock jump inside one fetch, before
// any timer runs. Post-spawn fetches: 0 primary read, 1 primary listener check, 2 secondary check.
@@ -190,6 +196,8 @@ describe("child-bound startup", () => {
};
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/exited/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("handles an asynchronous native spawn error without a PID", async () => {
const f = fixture();
@@ -280,3 +288,52 @@ describe("local startup reservation", () => {
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});
it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s old state and excludes a duplicate launch", async (oldState) => {
vi.useFakeTimers();
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-pending-start-"));
const f = fixture(), fetch = f.deps.fetch;
const previousState = oldState === "stale"
? JSON.stringify({ pid: 999, host: "127.0.0.2", port: 19874, db: "old.sqlite", startedAt: "old" })
: undefined;
if (previousState) f.files.set(STATE_FILE, previousState);
f.deps.isProcessAlive = pid => pid === f.child.pid && f.child.exitCode === null;
let ready = false;
f.deps.acquireStartLock = () => acquireDaemonStartLock(home);
f.deps.fetch = async url => {
const response = await fetch(url);
if (!ready) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
return response;
};
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${opts.port}`);
vi.stubEnv("RIGGED_URL", "");
try {
const began = Date.now(), result = startDaemon(opts, f.deps).catch((error: Error) => error);
await vi.runAllTimersAsync();
const outcome = await result;
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/pending.*12345/);
const message = (outcome as Error).message;
expect(message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect(message).not.toContain("127.0.0.2:19874");
expect(message).toContain("this check does not publish readiness, adopt the child or release the reservation");
expect(message).toContain("stop child PID 12345 and wait for exit");
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.child.exitCode).toBeNull();
expect(f.files.get(STATE_FILE)).toBe(previousState);
expect(fs.readFileSync(path.join(home, "daemon-start.lock"), "utf8")).toContain('"childPid":12345');
ready = true;
await expect(getDaemonStatus(f.deps)).resolves.toMatchObject({ state: "running", healthy: true, port: opts.port });
expect(f.files.get(STATE_FILE)).toBe(previousState); // A status probe never retroactively publishes start readiness.
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/already reserved/);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
}
});
+41 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, realpathSync, renameSync, rmSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";
@@ -13,6 +13,7 @@ import {
type DestroyDeps,
} from "../src/destroy-helpers.js";
import { destroyCommand } from "../src/commands/destroy.js";
import { readRecordedTmuxServerArgs } from "../src/tmux-attachment.js";
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
@@ -64,6 +65,45 @@ describe("destroy helpers", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("resolves the effective socket before stopping or deleting state and passes it to every session kill", async () => {
const args = ["-S", join(tmpDir, "selected.sock")];
writeFileSync(join(tmpDir, "tmux-server.json"), JSON.stringify({ version: 1, home: realpathSync(tmpDir),
mode: "isolated", pinned: true, socketPath: args[1] }));
const deps = makeDestroyDeps({ resolveTmuxServerArgs: vi.fn(readRecordedTmuxServerArgs) });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
plan.managedTmuxSessions = ["seat"];
await executeDestroy(plan, deps);
expect(deps.resolveTmuxServerArgs).toHaveBeenCalledWith(tmpDir);
expect(deps.killTmuxSession).toHaveBeenCalledWith("seat", args);
expect(vi.mocked(deps.resolveTmuxServerArgs!).mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(deps.stopDaemon).mock.invocationCallOrder[0]!);
});
it("leaves daemon, sessions and state intact if its pinned server record is missing", async () => {
writeFileSync(join(tmpDir, "tmux-server.initialized"), "openrig-tmux-pinned-v1\n");
const deps = makeDestroyDeps({ resolveTmuxServerArgs: readRecordedTmuxServerArgs });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
await expect(executeDestroy(plan, deps)).rejects.toThrow("Missing tmux server record");
expect(deps.stopDaemon).not.toHaveBeenCalled();
expect(deps.killTmuxSession).not.toHaveBeenCalled();
expect(deps.removePath).not.toHaveBeenCalled();
});
it.each(["missing", "malformed", "stale"])("keeps inherited cleanup on tmux's current server with a %s advisory record", async state => {
if (state === "malformed") writeFileSync(join(tmpDir, "tmux-server.json"), "{");
if (state === "stale") writeFileSync(join(tmpDir, "tmux-server.json"), JSON.stringify({ version: 1,
home: realpathSync(tmpDir), mode: "inherit", pinned: false, socketPath: join(tmpDir, "old.sock") }));
const deps = makeDestroyDeps({ resolveTmuxServerArgs: readRecordedTmuxServerArgs });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
plan.managedTmuxSessions = ["seat"];
await executeDestroy(plan, deps);
expect(deps.stopDaemon).toHaveBeenCalled();
expect(deps.killTmuxSession).toHaveBeenCalledExactlyOnceWith("seat", []);
});
it("buildBackupPath appends timestamp and resolves collisions", () => {
const target = join(tmpDir, ".openrig");
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
+91 -9
View File
@@ -504,17 +504,99 @@ describe("runDoctorChecks", () => {
}
});
it("port in use by OpenRig daemon -> pass", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true })) as unknown as typeof fetch;
// The listener's OS user is what /healthz reports (pid and euid): its own report, compared with the caller's.
const healthz = (body?: Record<string, unknown>) => async () => ({ ok: true, ...(body ? { json: async () => body } : {}) });
it("port in use by your own OpenRig daemon -> pass, naming the endpoint, the home and its pid", async () => {
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, env: { OPENRIG_HOME: "/home/me/.openrig" },
fetch: healthz({ ok: true, pid: 4242, euid: 1002 }) });
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("pass");
expect(result.message).toContain("your OpenRig daemon");
expect(result.message).toContain("the endpoint reports euid 1002, pid 4242");
expect(result.message).toContain("http://127.0.0.1:7433");
expect(result.message).toContain("/home/me/.openrig");
});
// A warning, not a failure: a share set up through config alone can't be told from the first-user accident, and it must
// keep working. The person still reads whose daemon it is and both ways on.
it("port served by another OS user's OpenRig daemon -> warn, naming both users as reported and how to run your own", async () => {
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, env: { OPENRIG_HOME: "/home/video075/.openrig" },
fetch: healthz({ ok: true, pid: 1116629, euid: 1001 }) });
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("warn");
expect(result.message).toContain("another OS user");
expect(result.message).toContain("the endpoint reports euid 1001, pid 1116629; your euid is 1002");
expect(result.message).toContain("/home/video075/.openrig");
expect(result.reason).toContain("self-report");
expect(result.fix).toContain("rig config set daemon.port");
expect(result.fix).toContain("rig daemon start");
expect(result.fix).toContain("OPENRIG_URL");
});
it.each([
["reports no OS user", { ok: true, pid: 77 }, 1002],
["answers without a JSON body", undefined, 1002],
["is checked where this platform has no effective user id", { ok: true, pid: 77, euid: 1001 }, null],
])("an OpenRig daemon that %s -> warn, owner unknown", async (_label, body, callerUid) => {
const deps = makeDeps({ checkPort: async () => false, callerUid: callerUid as number | null, fetch: healthz(body) });
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("warn");
expect(result.message).toContain("OS user is unknown");
expect(result.fix).toContain("rig config set daemon.port");
});
it("keeps passing when OPENRIG_URL selects that endpoint on purpose, and says whose daemon it is", async () => {
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, env: { OPENRIG_URL: "http://127.0.0.1:7433/" },
fetch: healthz({ ok: true, pid: 1116629, euid: 1001 }) });
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("pass");
expect(result.message).toContain("OPENRIG_URL");
expect(result.message).toContain("the endpoint reports euid 1001, pid 1116629; your euid is 1002");
});
it("takes OPENRIG_URL's localhost as the configured 127.0.0.1 endpoint", async () => {
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, env: { OPENRIG_URL: "http://localhost:7433" },
fetch: healthz({ ok: true, pid: 1116629, euid: 1001 }) });
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("pass");
expect(result.message).toContain("OPENRIG_URL");
});
// A listener that sends an OK header and never finishes its body must not hold doctor, or setup, before anything prints.
it("settles a /healthz body that never finishes as owner unknown within its bound, and releases the request", async () => {
// The port check's request carries a signal; doctor's other /healthz reads (the cmux check) don't.
const signals: AbortSignal[] = [];
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, ownerReportTimeoutMs: 50,
fetch: async (_url: string, init?: { signal?: AbortSignal }) => {
if (init?.signal) signals.push(init.signal);
return { ok: true, json: () => new Promise<unknown>(() => {}) };
} });
const started = Date.now();
const result = await runDoctorChecks(deps).portCheck;
expect(Date.now() - started).toBeLessThan(2000);
expect(result.status).toBe("warn");
expect(result.message).toContain("OS user is unknown");
expect(result.reason).toContain("didn't finish");
expect(signals).toHaveLength(1);
expect(signals[0]!.aborted).toBe(true);
});
it("settles a real listener's held /healthz body the same way, through the real fetch", async () => {
const { createServer } = await import("node:http");
const server = createServer((_req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.write('{"ok":true,'); });
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
try {
const deps = makeDeps({ checkPort: async () => false });
const { portCheck } = runDoctorChecks(deps);
const result = await portCheck;
expect(result.status).toBe("pass");
expect(result.message).toContain("OpenRig daemon");
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, ownerReportTimeoutMs: 100,
configStore: { resolve: () => ({ ...defaultConfig, daemon: { port, host: "127.0.0.1" } }) } as DoctorDeps["configStore"] });
delete deps.fetch;
const result = await runDoctorChecks(deps).portCheck;
expect(result.status).toBe("warn");
expect(result.reason).toContain("didn't finish");
} finally {
globalThis.fetch = origFetch;
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
});
});
+17
View File
@@ -344,6 +344,23 @@ describe("Down CLI", () => {
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
});
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
responseOverride = {
status: 200,
body: {
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
keptFiles: [kept],
},
};
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
});
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
expect(logs).toContain(` ${kept}`);
expect(exitCode).toBeUndefined();
});
it.each([
{ rigName: "workshop", isUniqueName: false },
{},
+3 -3
View File
@@ -124,14 +124,14 @@ describe("rig env", () => {
return prog;
}
it("env help explains managed-app usage", () => {
it("env help explains services and execution placement", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Inspect and control rig environment services");
expect(help).toContain("service-backed rigs and managed");
expect(help).toContain("Inspect and control rig services");
expect(help).toContain("for execution placement use rig environment");
expect(help).toContain("rig env status secrets-manager");
expect(help).toContain("rig env logs secrets-manager vault");
});
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import { environmentCommand } from "../src/commands/environment.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
vi.mock("node:child_process", async original => ({ ...await original<typeof import("node:child_process")>(), spawn: vi.fn() }));
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; });
it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => {
vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one";
const paths: string[] = [];
const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never,
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
paths.push(String(url)); const validation = init?.method === "POST";
if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 });
if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) {
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, 6000);
init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); });
}
return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 });
}) as typeof fetch });
const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) };
vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType<typeof spawn>; });
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]);
await vi.advanceTimersByTimeAsync(6500); await run;
if (mode !== "changed-generation") {
expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]);
expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1);
} else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); }
});
@@ -0,0 +1,23 @@
import { afterEach, expect, it, vi } from "vitest";
import { environmentCommand } from "../src/commands/environment.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { EnvironmentCommandDeps } from "../src/commands/environment.js";
afterEach(() => vi.restoreAllMocks());
it.each([undefined, "generation-one"])("sends the explicit cleanup selection with generation %s", async generation => {
const lifecycleDeps: LifecycleDeps = {
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE),
mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true),
};
const post = vi.fn(async () => ({ status: 200, data: { state: "confirmed" } }));
const deps: EnvironmentCommandDeps = { lifecycleDeps, clientFactory: () => ({ post }) as never };
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await environmentCommand(deps).parseAsync(["node", "rig", "effect", "binding-one", "--effect", "delete-owned", "--operation", "delete-one",
...(generation ? ["--generation", generation] : ["--resource-id", "compute-one"]), "--engine-socket", "/owned/engine.sock", "--json"]);
expect(post).toHaveBeenCalledWith("/api/environments/binding-one/effect", {
effect: "delete-owned", generation: generation ?? null, ...(generation ? {} : { resourceId: "compute-one" }), operationId: "delete-one", engineSocket: "/owned/engine.sock",
}, { timeoutMs: 120_000 });
expect(log).toHaveBeenCalled();
});
@@ -0,0 +1,82 @@
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { afterEach, expect, it, vi } from "vitest";
import { createHash } from "node:crypto";
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { AddressInfo } from "node:net";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { EnvironmentPlatform } from "../../daemon/src/domain/environment-platform.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { addGrant, revokeGrant, startMcpHttpServer } from "../src/mcp-http.js";
const cleanup: Array<() => unknown | Promise<unknown>> = [];
afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); });
const hash = (data: string) => createHash("sha256").update(data).digest("hex");
it("returns real artifact bytes under the authenticated work grant, refusing wrong seat and revoked credentials", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-http-")));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createDb(":memory:"); migrate(db, ALL_MIGRATIONS); cleanup.push(() => db.close());
const identity = join(root, "identity.sqlite"), idDb = createDb(identity);
idDb.exec("CREATE TABLE self_host_identity (singleton INTEGER PRIMARY KEY, host_id TEXT)");
idDb.prepare("INSERT INTO self_host_identity VALUES (1, 'environment-test-host')").run(); idDb.close();
vi.stubEnv("OPENRIG_DB", identity); setSelfHostId("environment-test-host");
cleanup.push(() => { setSelfHostId(null); vi.unstubAllEnvs(); });
const artifact = JSON.stringify({ task: "export-task", revision: 1, report: "useful bytes" });
let downloads = 0;
const resource = { id: "immutable-id", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
const driver = { version: 1 as const, list: async () => ({ complete: true, resources: [] }), create: async (input: { operationId: string }) => { resource.operationId = input.operationId; return resource; },
inspect: async () => resource, upload: async () => {}, read: async () => Buffer.alloc(0),
download: async (_binding: unknown, _source: string, destination: string) => { downloads++; writeFileSync(destination, artifact); } };
const queue = new QueueRepository(db, new EventBus(db)); queue.attachOutbox(new OutboxHandler(db));
const fixture = createTestApp(db, { appDeps: { queueRepo: queue, environmentDriver: driver, environmentArtifactRoot: join(root, "artifacts") } as never });
const rig = fixture.rigRepo.createRig("team"), node = fixture.rigRepo.addNode(rig.id, "team.worker", { runtime: "openshell-claude", cwd: root });
fixture.sessionRegistry.registerSession(node.id, "worker@team");
const policy = join(root, "policy.yaml"); writeFileSync(policy, "version: 1\n");
const platform = new EnvironmentPlatform(db, driver);
await platform.create({ version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "worker@team", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7782",
placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } });
await platform.project("binding-one", { operationId: "projection-one", generation: "generation-one", files: [] });
// This is the existing queue writer, not a separate guest task store.
const created = await fixture.app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@team" }, body: JSON.stringify({ qitemId: "export-task", destinationSession: "worker@team", body: "Return the selected report" }) });
expect(created.status, JSON.stringify(await created.clone().json())).toBeLessThan(300);
const daemon = await new Promise<ReturnType<typeof serve>>(resolve => {
const server = serve({ fetch: fixture.app.fetch, hostname: "127.0.0.1", port: 0 }, () => resolve(server));
});
cleanup.push(() => new Promise<void>(resolve => daemon.close(() => resolve())));
const daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
const tokenFile = join(root, "tokens.json");
const token = await addGrant(tokenFile, { name: "worker-grant", seat: "worker@team", rigs: ["team"], roots: [join(root, "artifacts")], workspace: false, owner: false });
const wrongToken = await addGrant(tokenFile, { name: "other-grant", seat: "other@team", rigs: ["team"], roots: [], workspace: false, owner: false });
const mcp = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile }); cleanup.push(() => mcp.close());
const connect = async (credential: string) => {
const client = new Client({ name: "environment-proof", version: "1" });
await client.connect(new StreamableHTTPClientTransport(new URL(mcp.url), { requestInit: { headers: { Authorization: `Bearer ${credential}`, "X-OpenRig-Principal": JSON.stringify({ source: "file", id: "forged" }) } } }));
cleanup.push(() => client.close()); return client;
};
const bad = new Client({ name: "environment-invalid", version: "1" });
await expect(bad.connect(new StreamableHTTPClientTransport(new URL(mcp.url), { requestInit: { headers: { Authorization: "Bearer invalid" } } }))).rejects.toThrow();
await bad.close();
const owner = await connect(token), other = await connect(wrongToken);
const request = { bindingId: "binding-one", operationId: "export-one", taskId: "export-task", generation: "generation-one", path: "report.json", sha256: hash(artifact), bytes: Buffer.byteLength(artifact),
schema: { format: "json-object/v1", fields: { task: "string", revision: "number", report: "string" }, required: ["task", "revision", "report"] }, context: { task: "export-task", revision: 1 } };
const denied = await other.callTool({ name: "rig_environment_export", arguments: request }); expect(denied.isError).toBe(true); expect(downloads).toBe(0);
const result = await owner.callTool({ name: "rig_environment_export", arguments: request }); expect(result.isError).not.toBe(true);
const receipt = result.structuredContent as Record<string, any>;
expect(receipt).toMatchObject({ actor: "worker@team", identityProvenance: "principal:v1", identityPrincipal: JSON.stringify({ source: "file", id: "worker-grant" }), sha256: hash(artifact) });
expect(readFileSync(receipt.path, "utf8")).toBe(artifact); expect(downloads).toBe(1);
const note = db.prepare("SELECT identity_provenance, identity_principal FROM queue_transitions WHERE qitem_id = 'export-task' ORDER BY transition_id DESC LIMIT 1").get();
expect(note).toMatchObject({ identity_provenance: "principal:v1", identity_principal: JSON.stringify({ source: "file", id: "worker-grant" }) });
expect(await revokeGrant(tokenFile, "worker-grant")).toBe(true);
await expect(owner.callTool({ name: "rig_environment_export", arguments: request })).rejects.toThrow();
expect(downloads).toBe(1);
});
+108
View File
@@ -262,6 +262,67 @@ describe("rig export + import", () => {
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
});
describe("export: a default export never replaces a file", () => {
const occupied = (...taken: string[]) => vi.fn((p: string) => {
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
});
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
const program = new Command();
program.addCommand(exportCommand(exportDeps(deps)));
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
};
it("the first export writes rig.yaml", async () => {
const writeNewFile = occupied();
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
expect(logs).toContain("Exported to rig.yaml");
expect(logs.join("\n")).not.toContain("left unchanged");
});
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const exitCode = process.exitCode;
const logs = await run({ writeFile, writeNewFile });
expect(writeFile).not.toHaveBeenCalled();
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
expect(logs).toContain("Exported to rig-export.yaml");
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
expect(process.exitCode).toBe(exitCode);
});
it("occupied siblings are kept too", async () => {
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
expect(logs).toContain("Exported to rig-export-3.yaml");
});
it("-o replaces the file it names, including rig.yaml", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
expect(writeNewFile).not.toHaveBeenCalled();
expect(logs).toContain("Exported to rig.yaml");
});
it("a real write failure stays a failure, with no success reported", async () => {
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const program = new Command();
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
expect(writeNewFile).toHaveBeenCalledTimes(1);
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
} finally {
log.mockRestore();
}
});
});
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
const writeFile = vi.fn();
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
@@ -330,6 +391,53 @@ edges: []
}
});
it("#1631 export warns for a missing required member file and accepts it when copied beside the output", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-member-files-"));
const source = path.join(root, "source"), out = path.join(root, "out");
fs.mkdirSync(source); fs.mkdirSync(out);
fs.writeFileSync(path.join(source, "member.md"), "member guidance");
const yaml = `version: '0.2'
name: member-files
pods:
- id: dev
label: Dev
members:
- id: worker
agent_ref: local:agents/worker
runtime: codex
profile: default
cwd: .
startup:
files:
- path: member.md
required: true
- path: optional.md
required: false
edges: []
edges: []
`;
srv.exports.set("member-startup", { root: source, yaml });
try {
const output = path.join(out, "rig.yaml");
const client = { baseUrl: "http://127.0.0.1", get: vi.fn(async () => ({ status: 200, data: [{ id: "member-startup", name: "member-startup", archivedAt: null }] })),
getText: vi.fn(async () => ({ status: 200, data: yaml, headers: new Headers({ "X-Rig-Root": encodeURIComponent(source) }) })) } as unknown as DaemonClient;
const deps = exportDeps({ writeFile: (p, text) => fs.writeFileSync(p, text), clientFactory: () => client });
const run = () => new Command().addCommand(exportCommand(deps))
.parseAsync(["node", "rig", "export", "member-startup", "-o", output]).then(() => {});
const missing = (await captureLogs(run)).join("\n");
expect(missing).toContain("Referenced file 'member.md'");
expect(missing).not.toContain("optional.md");
fs.copyFileSync(path.join(source, "member.md"), path.join(out, "member.md"));
const delivered = (await captureLogs(run)).join("\n");
expect(delivered).not.toContain("Referenced file 'member.md'");
expect(fs.readFileSync(path.join(out, "member.md"), "utf8")).toBe("member guidance");
expect(fs.readFileSync(output, "utf8")).toBe(yaml);
} finally {
srv.exports.delete("member-startup");
fs.rmSync(root, { recursive: true, force: true });
}
});
it.each(["copy", "existing", "symlink", "missing", "remote", "relative"].filter(mode => mode !== "symlink" || process.platform !== "win32"))("export: shares culture and explains nonportable refs/name (%s)", async mode => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-share-"));
const source = path.join(root, "source"), out = path.join(root, "out"), outside = path.join(root, "outside");
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { recordHostObservation, loadHostBindings } from "../src/host-bindings.js";
import { validateHostRegistry } from "../src/host-registry.js";
describe("host bindings for prototype-named aliases", () => {
it.each(["constructor", "toString", "valueOf"])("records and reloads the first observation for %s", (alias) => {
const dir = mkdtempSync(join(tmpdir(), "host-binding-alias-"));
try {
expect(validateHostRegistry({ hosts: [{ id: alias, transport: "http", url: "http://example.test" }] }, "test").ok).toBe(true);
const path = join(dir, "host-bindings.json");
expect(recordHostObservation({ alias, observedHostId: "host-123", path }).outcome).toBe("bound");
expect(loadHostBindings(path).bindings[alias]?.hostId).toBe("host-123");
expect(recordHostObservation({ alias, observedHostId: "host-123", path }).outcome).toBe("confirmed");
expect(recordHostObservation({ alias, observedHostId: "host-456", path }).outcome).toBe("conflict");
expect(loadHostBindings(path).bindings[alias]?.conflict?.hostId).toBe("host-456");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadHostBindings, recordHostObservation, describeBindingConflict } from "../src/host-bindings.js";
import { mkdirSync as fs2Mkdir, utimesSync as fs2Utimes } from "node:fs";
import { resolveHost, type HostRegistry } from "../src/host-registry.js";
@@ -331,7 +332,7 @@ describe("F2 — concurrency guard: conflict records survive concurrent writers"
const child = spawn(process.execPath, ["--import", "tsx", "-e",
`import { recordHostObservation } from ${JSON.stringify(new URL("../src/host-bindings.ts", import.meta.url).href)};` +
`recordHostObservation({ alias: ${JSON.stringify(alias)}, observedHostId: ${JSON.stringify(id)}, path: ${JSON.stringify(path)} });`,
], { cwd: new URL("..", import.meta.url).pathname, stdio: "ignore" });
], { cwd: fileURLToPath(new URL("..", import.meta.url)), stdio: "ignore" });
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`writer ${alias} exited ${code}`))));
child.on("error", reject);
});
@@ -0,0 +1,175 @@
// OPR.0.4.6.MH1 FR-1 extension (issue #2128) — the `rig host edit` and
// `rig host remove` COMMANDS themselves: flag→field mapping, the id+transport
// fixed rule surfacing as loud CLI errors, empty-string clearing, plain
// selection notices (never a silent switch), and the --json shape.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { Command } from "commander";
import { hostCommand } from "../src/commands/host.js";
import { addHostEntry, loadHostRegistry } from "../src/host-registry.js";
describe("rig host edit / rig host remove (issue #2128 command path)", () => {
let dir: string;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostedit-"));
savedEnv = {};
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_HOST_SELECTED"]) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
process.env.OPENRIG_HOME = dir;
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
async function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
const out: string[] = []; const err: string[] = [];
const ol = console.log; const oe = console.error; const oc = process.exitCode;
process.exitCode = undefined;
console.log = (...a: unknown[]) => out.push(a.join(" "));
console.error = (...a: unknown[]) => err.push(a.join(" "));
let exitCode: number | undefined;
try {
await fn();
exitCode = process.exitCode as number | undefined;
} finally {
console.log = ol;
console.error = oe;
process.exitCode = oc;
}
return { out, err, exitCode };
}
function run(argv: string[]) {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
function seed() {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }).ok).toBe(true);
expect(addHostEntry({ id: "h1", transport: "http", url: "http://h1:7433", bearer_env: "H1_TOKEN" }).ok).toBe(true);
}
it("edit changes fields through the writer; id+transport stay fixed", async () => {
seed();
const { out, exitCode } = await capture(() => run(["edit", "vm-a", "--target", "vm-b.local", "--user", "bob"]));
expect(exitCode).toBeUndefined();
const loaded = loadHostRegistry();
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
const entry = loaded.registry.hosts.find(h => h.id === "vm-a");
expect(entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob" });
expect(out.join("\n")).toContain("Updated host 'vm-a'");
});
it("edit clears an optional field with an empty string", async () => {
seed();
const { exitCode } = await capture(() => run(["edit", "vm-a", "--user", ""]));
expect(exitCode).toBeUndefined();
const loaded = loadHostRegistry();
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
const entry = loaded.registry.hosts.find(h => h.id === "vm-a")!;
expect(entry.user).toBeUndefined();
expect(entry.notes).toBe("old");
});
it("edit on an unknown id is exit 1 naming the known ids", async () => {
seed();
const { err, exitCode } = await capture(() => run(["edit", "nope", "--target", "x"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("unknown host id 'nope'");
expect(err.join("\n")).toContain("vm-a");
});
it("edit with NO flags is refused (nothing to change)", async () => {
seed();
const { err, exitCode } = await capture(() => run(["edit", "vm-a"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toMatch(/no changes/i);
});
it("edit refuses a field that is not editable for the transport", async () => {
seed();
const { err, exitCode } = await capture(() => run(["edit", "vm-a", "--url", "http://x"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("not editable");
const { err: err2, exitCode: code2 } = await capture(() => run(["edit", "h1", "--target", "somewhere"]));
expect(code2).toBe(1);
expect(err2.join("\n")).toContain("not editable");
});
it("edit of the SELECTED host says so plainly (selection unchanged)", async () => {
seed();
process.env.OPENRIG_HOST_SELECTED = "vm-a";
const { out, exitCode } = await capture(() => run(["edit", "vm-a", "--notes", "moved"]));
expect(exitCode).toBeUndefined();
const joined = out.join("\n");
expect(joined).toContain("selected host");
expect(joined).toContain("selection");
expect(process.env.OPENRIG_HOST_SELECTED).toBe("vm-a"); // never a silent switch
});
it("remove deletes only the entry and reports it plainly", async () => {
seed();
const { out, exitCode } = await capture(() => run(["remove", "vm-a"]));
expect(exitCode).toBeUndefined();
const loaded = loadHostRegistry();
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
expect(loaded.registry.hosts.map(h => h.id)).toEqual(["h1"]);
expect(out.join("\n")).toContain("Removed host 'vm-a'");
});
it("remove of an unknown id is exit 1 naming known ids", async () => {
seed();
const { err, exitCode } = await capture(() => run(["remove", "nope"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("unknown host id 'nope'");
});
it("remove of the SELECTED host shows the dangling selection plainly — never a silent switch", async () => {
seed();
process.env.OPENRIG_HOST_SELECTED = "vm-a";
const { out, exitCode } = await capture(() => run(["remove", "vm-a"]));
expect(exitCode).toBeUndefined();
const joined = out.join("\n");
expect(joined).toContain("was the selected host");
expect(joined).toContain("rig host select");
expect(process.env.OPENRIG_HOST_SELECTED).toBe("vm-a"); // selection untouched
expect(fs.existsSync(path.join(dir, "secrets"))).toBe(false); // no credential state touched
});
it("--json emits one parseable object with ok + entry", async () => {
seed();
const { out, exitCode } = await capture(() => run(["edit", "h1", "--notes", "n", "--json"]));
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(out.join("\n")) as { ok: boolean; entry: { id: string; notes: string } };
expect(parsed.ok).toBe(true);
expect(parsed.entry).toMatchObject({ id: "h1", notes: "n" });
const { out: out2, exitCode: code2 } = await capture(() => run(["remove", "h1", "--json"]));
expect(code2).toBeUndefined();
const parsed2 = JSON.parse(out2.join("\n")) as { ok: boolean; entry: { id: string } };
expect(parsed2.ok).toBe(true);
expect(parsed2.entry.id).toBe("h1");
});
it("edit on a MISSING registry is exit 1 with the loader's message", async () => {
const { err, exitCode } = await capture(() => run(["edit", "vm-a", "--target", "x"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("host registry not found");
});
});
@@ -0,0 +1,182 @@
// OPR.0.4.6.MH1 FR-1 extension (issue #2128, maintainer-approved boundary) —
// the registry WRITE path for `rig host edit` / `rig host remove`:
//
// - an edit keeps the host's id and transport FIXED (the boundary's first rule);
// - unknown fields fail loud with the allowed set for the transport (a typo'd
// flag must never silently no-op an edit);
// - an empty string clears an OPTIONAL field (user, notes) — clearing is how
// you drop a wrong value; required fields (target, url) stay non-empty;
// - a present-but-INVALID registry is never overwritten (add's rule, verbatim);
// - remove deletes ONLY the registry entry.
//
// The daemon twin (packages/daemon/src/domain/hosts/hosts-registry-writer.ts)
// mirrors these verbatim; byte-parity is pinned in
// packages/daemon/test/hosts-registry-parity.test.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { addHostEntry, loadHostRegistry, updateHostEntry, removeHostEntry } from "../src/host-registry.js";
describe("updateHostEntry (rig host edit write path)", () => {
let dir: string;
let regPath: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-edit-"));
regPath = path.join(dir, "hosts.yaml");
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it("round-trip: add an ssh host, edit target/user/notes, loader reads back the edit; id+transport fixed", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }, regPath).ok).toBe(true);
const res = updateHostEntry("vm-a", { target: "vm-b.local", user: "bob", notes: "moved" }, regPath);
expect(res.ok).toBe(true);
if (!res.ok) throw new Error(res.error);
expect(res.entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob", notes: "moved" });
expect(res.previous).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" });
const loaded = loadHostRegistry(regPath);
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
expect(loaded.registry.hosts).toHaveLength(1);
expect(loaded.registry.hosts[0]).toEqual({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob", notes: "moved" });
});
it("edit keeps the host id and transport fixed (both refusals, bytes untouched)", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const before = fs.readFileSync(regPath, "utf8");
const idRes = updateHostEntry("vm-a", { id: "renamed" }, regPath);
expect(idRes.ok).toBe(false);
if (!idRes.ok) expect(idRes.error).toContain("id");
const transportRes = updateHostEntry("vm-a", { transport: "http", url: "http://x" }, regPath);
expect(transportRes.ok).toBe(false);
if (!transportRes.ok) expect(transportRes.error).toContain("transport");
expect(fs.readFileSync(regPath, "utf8")).toBe(before);
});
it("edit on an unknown host id names the id and lists known ids", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const res = updateHostEntry("nope", { target: "x" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error).toContain("unknown host id 'nope'");
expect(res.error).toContain("Known host ids");
}
});
it("edit with no changes given is refused", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const res = updateHostEntry("vm-a", {}, regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toMatch(/no changes/i);
});
it("edit refuses fields that are not editable for the transport, naming the allowed set", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const res = updateHostEntry("vm-a", { url: "http://x" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error).toContain("url");
expect(res.error).toContain("ssh");
}
// and for an http host, ssh-only fields are refused
expect(addHostEntry({ id: "h1", transport: "http", url: "http://h1:7433" }, regPath).ok).toBe(true);
const sshRes = updateHostEntry("h1", { target: "somewhere" }, regPath);
expect(sshRes.ok).toBe(false);
if (!sshRes.ok) expect(sshRes.error).toContain("target");
});
it("edit clears an optional field with an empty string", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }, regPath).ok).toBe(true);
const res = updateHostEntry("vm-a", { user: "", notes: "" }, regPath);
expect(res.ok).toBe(true);
if (!res.ok) throw new Error(res.error);
const loaded = loadHostRegistry(regPath);
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
const entry = loaded.registry.hosts[0]!;
expect(entry.user).toBeUndefined();
expect(entry.notes).toBeUndefined();
expect(entry.target).toBe("vm-a.local");
});
it("edit refuses an invalid required value and leaves the registry untouched", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const before = fs.readFileSync(regPath, "utf8");
const res = updateHostEntry("vm-a", { target: "" }, regPath);
expect(res.ok).toBe(false);
expect(fs.readFileSync(regPath, "utf8")).toBe(before);
});
it("edit on a MISSING registry file is an error (nothing to edit)", () => {
const res = updateHostEntry("vm-a", { target: "x" }, regPath);
expect(res.ok).toBe(false);
});
it("edit refuses to touch an INVALID registry (never clobber)", () => {
fs.writeFileSync(regPath, "hosts: [not-valid-yaml");
const res = updateHostEntry("vm-a", { target: "x" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry");
expect(fs.readFileSync(regPath, "utf8")).toBe("hosts: [not-valid-yaml");
});
});
describe("removeHostEntry (rig host remove write path)", () => {
let dir: string;
let regPath: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-remove-"));
regPath = path.join(dir, "hosts.yaml");
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it("round-trip: add two hosts, remove one; loader reads back only the other", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
expect(addHostEntry({ id: "vm-b", transport: "http", url: "http://vm-b" }, regPath).ok).toBe(true);
const res = removeHostEntry("vm-a", regPath);
expect(res.ok).toBe(true);
if (!res.ok) throw new Error(res.error);
expect(res.entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-a.local" });
const loaded = loadHostRegistry(regPath);
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
expect(loaded.registry.hosts).toHaveLength(1);
expect(loaded.registry.hosts[0]!.id).toBe("vm-b");
});
it("remove an unknown id names it and lists known ids", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const res = removeHostEntry("nope", regPath);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error).toContain("unknown host id 'nope'");
expect(res.error).toContain("Known host ids");
}
});
it("remove on a MISSING registry file is an error", () => {
const res = removeHostEntry("vm-a", regPath);
expect(res.ok).toBe(false);
});
it("remove refuses to touch an INVALID registry (never clobber)", () => {
fs.writeFileSync(regPath, "hosts: [not-valid-yaml");
const res = removeHostEntry("vm-a", regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry");
expect(fs.readFileSync(regPath, "utf8")).toBe("hosts: [not-valid-yaml");
});
it("remove of the last entry leaves a valid empty registry", () => {
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
const res = removeHostEntry("vm-a", regPath);
expect(res.ok).toBe(true);
const loaded = loadHostRegistry(regPath);
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error(loaded.error);
expect(loaded.registry.hosts).toEqual([]);
});
});
+42
View File
@@ -87,3 +87,45 @@ describe("direct endpoint attribution", () => {
expect(requests).toHaveLength(1); expect(requests[0]?.headers["X-OpenRig-Session"]).toBe("rig-admin@ops@vm-origin");
});
});
describe("local instance proof for terminal attachment", () => {
it.each([
["local-instance", true], ["remote-instance", false], [undefined, false],
] as const)("compares the loopback endpoint's identity %s with this home's durable identity", async (target, expected) => {
seed("local-instance");
// Attachment also requires proof outside an agent seat and without OPENRIG_URL.
vi.stubEnv("OPENRIG_SESSION_NAME", ""); vi.stubEnv("RIGGED_SESSION_NAME", ""); vi.stubEnv("OPENRIG_URL", "");
const { factory, requests } = client(target);
expect(await factory("http://127.0.0.1:12345").isLocalInstance()).toBe(expected);
expect(requests.map(r => r.url)).toEqual(["http://127.0.0.1:12345/healthz"]);
});
it("does not accept the endpoint's identity when the local store is absent", async () => {
const { factory, requests } = client("remote-instance");
expect(await factory("http://localhost:7433").isLocalInstance()).toBe(false);
expect(requests).toEqual([]);
expect(existsSync(dbPath)).toBe(false);
});
it("does not use a caller-supplied origin or a registered remote as local proof", async () => {
seed("local-instance");
const { factory } = client("remote-instance");
const c = factory("http://localhost:7433");
c.originSelfHostId = "remote-instance";
expect(await c.isLocalInstance()).toBe(false);
const same = client("local-instance");
expect(await remoteDaemonClient(same.factory, "http://localhost:7433").isLocalInstance()).toBe(false);
expect(same.requests).toEqual([]);
});
it("bounds an unresponsive identity probe and cancels it", async () => {
seed("local-instance");
let signal: AbortSignal | null | undefined;
const c = new DaemonClient("http://127.0.0.1:12345", { timeoutMs: 25, fetchImpl: (async (_url, init) => {
signal = init?.signal;
return new Promise<Response>(() => {});
}) as typeof fetch });
expect(await c.isLocalInstance()).toBe(false);
expect(signal?.aborted).toBe(true);
});
});
@@ -0,0 +1,124 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "../src/client.js";
import { terminalCommand, type TerminalDeps } from "../src/commands/terminal.js";
import { readLocalOrigin } from "../src/local-origin.js";
import { sharedTuiTarget } from "../src/shared-tui.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.js";
import { shellQuote } from "../src/cross-host-executor.js";
vi.mock("../src/local-origin.js", () => ({ readLocalOrigin: vi.fn(() => "local-instance") }));
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://127.0.0.1:7433"),
}));
beforeEach(() => {
vi.mocked(readLocalOrigin).mockClear().mockReturnValue("local-instance");
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); process.exitCode = undefined; });
// A port forward serves the remote daemon's identity and bindings on loopback.
// Use the real transport/identity path; only HTTP responses and local launches are inert.
function forwardedClient(serverArgs: string[], mode: "inherit" | "isolated" | undefined, targetIdentity = "remote-instance") {
// A pending request must never override the effective mode of surviving seats.
const tmux = { serverArgs, ...(mode ? { mode, requestedMode: mode === "inherit" ? "isolated" : "inherit", migrationPending: true } : {}) };
const pane = { seat: "namesake", label: "namesake", paneCommand: ["tmux", ...serverArgs, "attach", "-t", "namesake"].map(shellQuote).join(" ") };
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const route = new URL(String(input)).pathname;
const routes: Record<string, unknown> = {
"/healthz": { selfHostId: targetIdentity },
"/api/terminal/status": { tmux },
"/api/ps": [{ rigId: "remote-kernel", rigName: "kernel" }],
"/api/rigs/remote-kernel/nodes": [{ logicalId: "operator.human", runtime: "terminal",
canonicalSessionName: "namesake", tmuxAttachCommand: pane.paneCommand, tmuxServerArgs: serverArgs, tmuxServerMode: mode }],
"/api/terminal/preview": { planId: "remote-plan", status: {}, tmux, composed: {
id: "kernel", localTmuxServer: serverArgs, opened: [pane],
pages: [[pane]], absent: [], degraded: [],
} },
};
return new Response(JSON.stringify(routes[route] ?? {}));
});
return { client: new DaemonClient("http://127.0.0.1:7433", { fetchImpl: fetchImpl as typeof fetch }), fetchImpl };
}
describe.each([{ serverArgs: [] }, { serverArgs: ["-S", "/private/remote.sock"] }])("forwarded isolated tmux attachment ($serverArgs)", ({ serverArgs }) => {
it("refuses terminal attach before spawning a local namesake", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
const attachTmux = vi.fn(async () => 0);
const deps: TerminalDeps = { lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], clientFactory: () => client, attachTmux };
await new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "attach", "namesake"], { from: "user" });
expect(attachTmux).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("instance identity"));
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/terminal/status", "/healthz"]);
});
it("refuses to resolve a shared terminal from the forwarded daemon", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
await expect(sharedTuiTarget(client)).rejects.toThrow("instance identity");
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/ps", "/api/rigs/remote-kernel/nodes", "/healthz"]);
});
it("refuses a desktop window before launching or creating a local viewer", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
const deps: WindowDeps = { platform: "linux", env: {}, exists: vi.fn(() => false), exec: vi.fn(async () => ""),
launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "unused", herdrConfig: vi.fn(() => "unused") };
const result = await openTerminalWindow(client, "saved:kernel", "tmux", deps);
expect(result).toMatchObject({ ok: false, opened: [], windowAttempted: false });
expect(result.error).toContain("instance identity");
expect(deps.exec).not.toHaveBeenCalled();
expect(deps.launch).not.toHaveBeenCalled();
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/terminal/preview", "/healthz"]);
});
});
async function openLocalAttachments(client: DaemonClient, serverArgs: string[]) {
const attachTmux = vi.fn(async () => 0);
const deps: TerminalDeps = { lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], clientFactory: () => client, attachTmux };
await new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "attach", "namesake"], { from: "user" });
expect(attachTmux).toHaveBeenCalledExactlyOnceWith({ session: "namesake", serverArgs, readOnly: false });
expect(process.exitCode).toBe(0);
await expect(sharedTuiTarget(client)).resolves.toEqual({ session: "namesake", serverArgs });
const exec = vi.fn(async (file: string, args: string[]) => {
if (file === "/bin/sh") return "/fixture/tmux";
if (file === "/usr/bin/osascript") return "window";
if (args.at(-1) === "default-size") return "120x40";
if (args.at(-1) === "window-size") return "latest";
if (args.includes("#{pane_id}")) return "%1";
return "";
});
const windowDeps: WindowDeps = { platform: "darwin", env: { TERM_PROGRAM: "Apple_Terminal" }, exists: () => false,
exec, launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "inherit-check", herdrConfig: vi.fn(() => "unused") };
const result = await openTerminalWindow(client, "saved:kernel", "tmux", windowDeps);
expect(result).toMatchObject({ ok: true, opened: ["namesake"], window: { app: "Terminal" } });
expect(exec).toHaveBeenCalledWith("/fixture/tmux", [...serverArgs, "new-session", "-d", "-s", "openrig-view-inherit-check", "-n", "view-1", "-P", "-F", "#{pane_id}", expect.any(String)]);
}
describe.each([
{ mode: undefined, serverArgs: [] },
{ mode: "inherit" as const, serverArgs: [] },
{ mode: "inherit" as const, serverArgs: ["-L", "named"] },
{ mode: "inherit" as const, serverArgs: ["-S", "/private/inherited socket"] },
])("legacy and effective inherit attachments ($mode, $serverArgs)", ({ mode, serverArgs }) => {
it.each(["different-local-instance", null])("keeps all three local paths working with local identity %j", async origin => {
vi.mocked(readLocalOrigin).mockReturnValue(origin);
const { client, fetchImpl } = forwardedClient(serverArgs, mode);
await openLocalAttachments(client, serverArgs);
expect(readLocalOrigin).not.toHaveBeenCalled();
expect(fetchImpl.mock.calls.some(([url]) => new URL(String(url)).pathname === "/healthz")).toBe(false);
});
});
it("opens all three isolated attachments when the instance identity matches", async () => {
const serverArgs = ["-S", "/private/local-instance.sock"];
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated", "local-instance");
await openLocalAttachments(client, serverArgs);
expect(fetchImpl.mock.calls.filter(([url]) => new URL(String(url)).pathname === "/healthz")).toHaveLength(3);
});
Loaded 100 of 501 files, more files were not shown because too many files have changed in this diff. Show more