Compare commits

...
Author SHA1 Message Date
dev-driver 5da1dfb409 docs(roadmap): name 0.7.11 as the current release 2026-11-11 07:25:55 +00:00
billbenetti 3a53a07a50 fix(daemon): stuck sweep counts a finding once, and a bound terminal seat's wake isn't replayed at start-up (#2939) (#2946)
The stuck sweep's findingsRouted counted every refresh of a still-open finding, so it grew forever, and a start-up retry kept re-filing the failed wake of a seat bound to a plain terminal after its finding was closed. findingsRouted now counts newly created findings, a findingsOpen gauge reports the findings open now, and lastOutcome keeps its meaning; a pass that throws keeps its previous gauge, and findings it created before the error may not reach the cumulative count. A bound terminal seat reads as no receiver for the start-up retry, checked under both the address and the resolved physical session name; its first failed wake still records, and the pending row stays visible and can replay if the seat becomes an agent. The first-send predicate is unchanged; the capped wake ladder is too. The daemon HTTP clients reference describes the counters. Reported and fixed by billbenetti.
2026-11-10 23:13:48 -08:00
Mike Schwarz 049637a44a fix: withhold an unsafe receiving-agent description; say where unrouted Slack goes with no receiver (#2938)
Follow-ups to #2923 and #2924. The settings view withholds the receiving agent's description when agents.operator_session holds credential-bearing or unsafe text, as the setting's own value already is. Slack's unchosen inbound destination says that, with no receiving agent on this host, unrouted messages go to the compatibility destination as before, and when the receiver couldn't be read or wasn't resolved it says OpenRig couldn't tell, instead of calling it absent. The rigs skill and the gateway participants reference say a pairing request can name a person other than the default.
2026-11-10 22:55:04 -08:00
Mike Schwarz 8990e1cb6e feat(daemon): record a prompt hook's own observation beside each send's delivery record (#2943)
At UserPromptSubmit, OpenRig's activity relay posts a bounded raw observation of the prompt: its SHA-256 and byte length, the native session, Claude's prompt id or Codex's turn id, and the emitter's generation. It never sends the prompt text. The daemon validates and keeps that observation as its own record. Neither harness's payload carries an OpenRig delivery id, so nothing attributes the observation to an OpenRig message, and each delivery's input boundary stays unknown: "input boundary observed" isn't produced at today's harness versions. The hook's stdout, exit and activity behaviour are unchanged, and a relay or record failure never blocks a send. Builds on PR-2's delivery-evidence record. Seat transport S02, PR-4.
2026-11-10 22:24:58 -08:00
Mike SchwarzandKM199 837186fd9d fix(transport): a fresh idle hook doesn't let a send type into a current Claude dialog (finishes #2513) (#2947)
After a Claude seat's Stop hook, the hook stays fresh for 15 seconds, and a fresh idle hook was trusted for an ordinary send without reading the pane, while a fresh running hook and any hook older than 15 seconds already got a pane check for a visible dialog. A person who opens a permission selector, a picker or a question in those seconds fires no new hook, so a wake or a peer's message could be typed into the dialog. A fresh idle hook now gets the same current-dialog check: a selection prompt or a permission prompt refuses the send with target_needs_input, and nothing is typed or pressed into it. A queue wake refused this way is retried by the existing prompt-clear retry; a direct rig send gets the refusal back. A plain draft stays the delivery guard's. A draft whose text quotes a numbered question, an option and the selection footer reads as a selection prompt and now refuses within the 15 seconds as well, as it already did after them and on a fresh running hook. The cost is one readiness probe per send in that window. Finishes #2513 by KM199, who found the gap and shaped the fix.

Co-authored-by: KM199 <[email protected]>
2026-11-10 22:05:24 -08:00
Mike Schwarz aaf986bf88 feat: a runner's permission answer goes to the receiving agent, which decides while the runner waits (#2934)
A person's answer to a runner's permission request, a choice or their own words, now goes to this host's receiving agent, which decides that one request while the runner waits: at once on a clear answer, asking back when the words aren't clear. The decider is the receiving agent unless that is the requesting seat itself, else the kernel operator, never a seat picked because it's running. Where no agent can decide, the answer and the request are kept and nothing runs; the person can decide directly with rig seat decide-permission <ask> --option <id>, or set up a receiving agent. A failed handoff or an ordinary close keeps the person's words and never turns them into the default permit; a request closed with genuinely no answer keeps the existing default. The decision is recorded with who decided and why, and a person's approve or deny from the Feed or Steering keeps its own verb and reason in the audit. 0.7.12 slice 05, PR-3.
2026-11-10 21:48:35 -08:00
James B. Shields c6ed1623c7 test(observer): make shared-directory rejection independent of umask (#2944)
mkdirSync's mode is masked by the process umask, so under umask 077 the observer test's "shared" directory was created 0700 and the shared-directory rejection case failed for the fixture's reason, not the product's. The test now sets 0755 explicitly and checks the mode before asserting the rejection. Production code and the existing rejection and side-effect assertions are unchanged.
2026-11-10 21:40:18 -08:00
billbenetti 28f11513a9 perf(daemon): the stuck sweep skips the waiting view on its own reads and yields by elapsed time too (refs #2649) (#2926)
The stuck sweep's own reads (11 getById and 5 finder reads) pass waiting: false, so the sweep stops building a waiting view it never uses; the default and every other reader are unchanged. The sweep also yields after 50 ms of work since its last yield, as well as every 50 rows as in #2649, across every loop of the pass, with the clock read again after each yield. When a successor-index build takes at least half of a timed stretch, the rest of the pass uses the per-row lookup instead of rebuilding the index after every yield. Writes, receipts, #2649's invalidation and rejudgment, and single flight are unchanged. Refs #2649 and #161: the smaller shape suggested in #161, by billbenetti.
2026-11-10 21:30:50 -08:00
Mike Schwarz 5cc1d652ff refactor(daemon): decide a seat's lifecycle by kind at the seat boundary (#2941)
#2932 put send, capture, the activity probe and target resolution behind one seat-kind resolver, but restore, its preview, teardown and a fresh launch's refusal guidance still compared a binding's attachment type with external_cli in nine places. seat-kinds.ts gains seatProcessOwner: external for an external CLI seat joined with rig attach (OpenRig neither launches nor stops it and leaves it attached), openrig for every other kind, including an unknown stored kind, which keeps today's path. The nine places now ask it. Nothing a person or agent sees changes. Seat transport S02, PR-3.
2026-11-10 21:27:52 -08:00
Mike Schwarz a792fc5b4a feat(daemon): record what each send's own result shows about its delivery (#2937)
A send's result says what happened only to its caller, at that moment, so "did this message reach the agent?" could only be inferred afterwards from the screen. Each send now emits one transport.delivery_evidence event in one place after it returns, derived only from its own result: the seat's session, pane, occupant generation and kind (read before the send, so a new occupant doesn't relabel it), the message and attempt ids, which outbox rows were admitted (or unknown when a row couldn't be read), what was written (submitted, text only, submit only, text with an unknown Enter, uncertain, none or unknown) and what the screen check showed. It never claims more than the result shows, and the send path is unchanged. Diagnostic event types stay out of the activity feed's caps. Seat transport S02, PR-2, building on PR-1's seat-kind resolver.
2026-11-10 21:23:20 -08:00
Mike Schwarz 03b32cbee1 fix(refocus): claim pending work across overlapping hooks (#2936)
When two refocus hook handlers ran for the same prompt (the packaged plugin hook and a handler added in settings both run, in parallel), each read the same due state and both delivered the packet, and a PostCompact arriving during a delivery could be overwritten by the later write. The refocus hook now claims one delivery under a short per-occupant state guard (an exclusive link with process and token ownership, at most 200 ms per phase), renders outside it, then rereads and consumes only the pending revision it claimed. A second handler for the same prompt id doesn't deliver again; a PostCompact, new threshold work or a different prompt's on-demand request that arrives during rendering keeps a fresh revision for the next prompt. A dead owner never blocks: its link is kept and a successor path is used. When the guard or owner can't be read, the hook reports it and delivers. Different occupants keep separate state. It never infers a prompt's identity from its text.
2026-11-10 21:07:57 -08:00
Mike Schwarz 72b86fc022 feat: a pairing request says who it went to and who reads the answer (#2935)
When you start a host pairing from another machine, the reply now says who on the target was asked and who reads their answer. The POST /api/hosts/pair-request reply adds the person asked and either the receiving agent (with any note about the setting) or the target's own explanation of why no agent reads answers there; rig host pair prints those lines. When the target can't tell whether an agent reads answers, the CLI prints the target's words rather than claiming none does. An older target that doesn't send these fields keeps today's general text. A target where nobody is registered, including one where nobody ever was, now answers the request with how to register a person on the target, worded for the person asking. No new refusal or error code. For the 0.7.12 person-setup work; it reports through the default receiver from #2923.
2026-11-10 21:06:09 -08:00
Mike Schwarz 5aa0016c98 feat: pairing answers go to the receiving agent; decide directly on the TUI's pairing settings (#2931)
A person's answer to a host pairing request, an Approve or Deny click or their own words in the Feed, Steering or Slack, no longer decides by itself. With a receiving agent, the agent reads it with the request's context and decides with rig host approve or deny. With none, the answer is kept, the person gets one receipt naming the direct controls, and an agent set before the request expires receives the whole kept answer once, with a pointer to the request. A Steering Deny or Drop, which closes the request, still refuses it.

The TUI has a new Settings > Instance & work > Pairing requests page, read from GET /api/hosts/pair-requests (no credential or pair id listed). It shows each request's code, requester, expiry, status and any kept answer, and Approve or Deny decides through the existing authenticated pair-decision after reading the request again; an ambiguous failure reads as unknown and is never sent twice. The requester's name and anything else from the request is escaped before it's shown. The Feed item for a pairing request links to that page. Builds on #2923's default receiver; for the 0.7.12 Needs-you outcome.
2026-11-10 21:04:37 -08:00
Mike Schwarz 8648ec93f1 fix(delivery): read Claude's context hint below the composer (#2942)
For #2925 (control B). The draft-aware guard finds Claude's input box by climbing from the last non-blank row and skipping the status rows it recognises. Claude's context hint under the mode footer, such as "~66k uncached · /clear to start fresh", wasn't one of them, so it was read as the footer and the input box couldn't be read: every automatic delivery was held as draft_input_unknown while rig capture read the box as empty. The footer scan now recognises that hint with one anchored pattern, the same kind of fix as the earlier update-banner and statusLine rows. Reproduced in an isolated 68x30 tmux pane with the reported frame: unchanged main held the send as unknown; with the change the message submits once and a real typed draft stays untouched. A control with the seat name in the input box's top rule passes too. No new refusal or timeout.
2026-11-10 21:03:06 -08:00
Mike Schwarz c92b4f3c93 fix(delivery): ignore Claude's drawn cursor blank (#2933)
For #2925. On an idle Claude seat with the typing guard set to draft-aware, every automatic delivery was held as draft_input_busy while rig capture read the input box as empty. The guard's strict reader counted Claude's own drawn cursor, a single inverse-video blank at the start of the composer, as typed text; the capture annotation already trimmed it. The strict reader now recognises that one drawn blank at the start of a one-row Claude composer and reads the box as empty. Real draft text, ordinary spaces, a moved cursor and multi-row input keep the existing protection, and the explicit draft-aware mode's handling of unreadable input is unchanged. No new refusal or timeout. Reproduced in an isolated 68x31 tmux 3.6 pane with the reported frame: unchanged main held the send, and with the change the message submits once while a real draft stays untouched. It doesn't explain the reported delay of about fifteen minutes before the first hold.
2026-11-10 19:05:22 -08:00
Mike Schwarz 85b4ccf27a docs: clarify when seats receive new guidance (#2930)
Three corrections to the agent startup guide's section on how a running seat gets new guidance. A send can be retained rather than delivered, so the guide no longer promises next-turn delivery or relative order. A managed refocus after compaction waits for an eligible prompt, which may not be the immediate next one. And rig walk reads each named file and sends its contents, unlike a pointer to a file that the seat still has to read. Finding a file, a hook picking it up and the agent reading it stay separate facts. Docs only.
2026-11-10 18:37:30 -08:00
Mike Schwarz eb73d75edd refactor(daemon): resolve a seat's kind at one boundary for send and capture (#2932)
Five places in session-transport.ts (send, its wait path, capture, the activity probe and target resolution) each compared a seat's attachment type with external_cli on their own. A new seat-kinds.ts resolves the kind once: tmux (also for a legacy row with no value), external_cli, or unknown with the stored value, so an unknown value is named rather than silently read as tmux, while keeping today's tmux path. The five branches now ask it. No result, error text or refusal changes. It's the first step of one seat boundary for delivery evidence, and it builds on rlch's SeatKind facade and attachment key proposed in #1429, with credit.
2026-11-10 18:16:14 -08:00
Mike Schwarz 1f30867775 docs: say how queueCounts treats a row tagged with several slices (#2929)
For #2902's queueCounts. The slice-list section of the daemon HTTP clients reference now says how a row tagged with several slices or missions is counted. Its tags count only when they all name the same physical slice or mission (a slice's folder name and its frontmatter id, for example). A row that names two distinct slices, missions or projects counts nowhere in queueCounts, while the older qitemCount can include it under several tagged slices, subject to the existing project filter and the newest 500 rows per slice. Docs only.
2026-11-10 18:13:32 -08:00
Mike Schwarz e89031d211 test(refocus): skip mode-000 denial cases as root (#2928)
Follow-up to #2916. Two refocus-record test cases rely on chmod 000 causing EACCES, which root doesn't get. They now skip when process.getuid?.() === 0, with the reason in their names, following the existing root skip in refocus-notes-local.test.ts. The other eight cases still run, and non-root runs keep both. Test only.
2026-11-10 17:56:14 -08:00
Mike Schwarz 1d920e756a docs(setup): offer to register the person, and fix the empty-registry hint (#2924)
When setup finds no registered person, the installer skill and the operator's guidance now offer to register the person installing, in the agent's own conversation: rig gateway human add with --default, only on their yes. An existing default is kept and never replaced unless they ask; with several people the agent never picks one, and rig gateway default runs only on their choice. Nothing is registered silently. The empty-registry hint from rig gateway human list, which still asked for --binding, now names the current command, and the pinned copies of the skill change only by that string.
2026-11-10 17:54:32 -08:00
Mike Schwarz 0fbfbf75a3 fix(queue): describe update delivery without assuming Slack (#2927)
Follow-up to #2909. A queue create that marks an update to a person with --alert is refused, as before. The refusal still says to send it without --alert, and now says updates are notices whose delivery depends on the person's connector and settings, so it's true for Slack, a relay connector and a person with no connector. The refusal, its error code and every delivery path are unchanged; the existing exact assertion is updated.
2026-11-10 17:52:58 -08:00
Mike Schwarz 4fa7ee0ab6 feat(daemon): one default receiving agent for answers nothing else reads (#2923)
Some answers have no asking agent able to read them: a person's answer to a host pairing request, the notice when a scheduled mode ends, and Slack messages with no route. Each chose its own receiver. A new resolver gives one answer: agents.operator_session when it names an agent's session, otherwise the kernel operator when its seat exists, otherwise none. It reports where the choice came from and the receiver's state (running, stopped or unknown), and never picks another seat because one is stopped.

Pairing's owner, the scheduled mode end and Slack's unrouted destination now use it. A Slack inboundDestination saved in the config file is kept exactly as written, including one equal to the old default; only an absent field, or the explicit default-receiver choice, inherits from the resolver, and channel and thread routes still come first. When the setting is invalid or can't be read, pairing, mode end and Slack say so instead of calling it unset. rig config and the TUI's settings show the effective receiving agent, where it came from and its state.
2026-11-10 17:51:03 -08:00
Mike Schwarz a5bda7fd31 feat(daemon): seat status shows guidance written after the agent loaded it (#2920)
A source or guidance edit isn't proof that a running agent has seen it. rig seat status now shows, for each of OpenRig's managed guidance blocks in a seat's file, whether it was written before or after the session's recorded start (startup finishing, or session registration), from the existing managed-block write records. It claims only that chronology, never that the agent loaded or read the guidance. A block reads unknown, with the reason, when its write record is missing, when the bytes in the file don't match the record, when only part of the block is present, or when a time can't be parsed. The agent startup guide explains that finding a file, a hook picking it up and the agent reading it under its own permission settings are separate facts, and names the explicit-read routes (reading the file, a send, a walk, a refocus) beside a restart, giving Rig a context-only adoption step.
2026-11-10 17:04:30 -08:00
Mike Schwarz bcc54af521 feat(daemon): every seat gets a pointer to its skills, help and context (#2918)
A seat on an older rig could start or restore without any pointer to where its skills, help and context live. Every seat now gets a short managed context-entry block beside openrig-start.md, which is unchanged. The block names the openrig-skills router, rig skill loadout --seat self, rig context get help and rig context list, rig context work-install, and the seat's and the rig's LEARNED.md. It's delivered on fresh starts and restores, and legacy seats get it on every route: the restore replay, a launch from the saved selection, a fresh launch, continuation, the runtime switch, an exact resume's put-back, and a handover's fresh-start text. It's never a precondition. The built-in is recognised only by its install identity, and an older saved entry without an owner root reads as unknown, so the rest of the selection is kept. The router-line setup preview now says a no skips only that line. Delivery isn't proof the agent read it.
2026-11-10 15:56:35 -08:00
Mike Schwarz 6e85d4adf2 feat(daemon): seat status shows what a seat's refocus hook has recorded (#2916)
A person diagnosing a seat that didn't refocus had no way to see what the refocus hook had actually recorded. rig seat status now shows it, read from the hook's own state files: the last record, whether it belongs to the current conversation (by resume token), and a no-identity marker. Missing and uninspectable are kept apart: an empty, invalid or unreadable record reads as unknown with the reason, never as recorded or as no record, and history counts only this seat's own files. With no record, it says where the hook runs. restore-check's no-channel evidence names that limit, and its statuses are unchanged. This reports the hook's record. It doesn't prove a successful refocus, that the hook loads now, or that the agent used the context.
2026-11-10 15:49:31 -08:00
ziadeh b6ab744345 fix(cli): rig daemon start archives a start reservation left from an earlier boot (#2915)
A rig daemon start that missed its readiness deadline keeps its start reservation, as designed. After a reboot, that reservation used to block every later start until the person renamed it by hand. Now a start that finds a reservation from an earlier boot archives it and says so in one line. It acts only on positive evidence: a different boot id (Linux boot_id, macOS kern.bootsessionuuid), or for an older reservation without one, creation and modification before the current boot with every recorded PID positively dead. One start wins any race: the archive is a hard link named for the inspected file, which never replaces an existing name, so a second start refuses instead of moving a fresh reservation. The same boot, a live or uninspectable PID, and an unreadable reservation keep today's refusal and guidance. For #2912.
2026-11-10 15:46:46 -08:00
James B. Shields 52cc63da89 feat(daemon): add observed queue counts to slice lists (#2902)
Slice list responses (rig scope slice ls and GET /api/slices) now include queueCounts: one count for each queue state, derived from the queue and refreshed as rows change. A row counts toward a slice when its tags identify that one physical slice, including rows the commands create with only slice: and mission: tags. A row whose tags match more than one slice, or that names a different project or mission, counts nowhere, and no row is counted twice. A known empty scope returns zeros, and an unresolved scope, or a slice whose candidates can't all be read, returns null, so unknown stays apart from zero. queueCounts is documented beside qitemCount. Authored status, display, filters and the CLI are unchanged. Refs #2729.
2026-11-10 14:38:33 -08:00
Mike Schwarz 60a4a63035 fix(cli): recap-write writes to the seat folder every reader uses (#2914)
A recap written under a seat's member id or logical id could land in a folder that restore and the world profile never read, so the next occupant didn't get it. rig context recap-write now defaults --rig and --seat to the calling seat, and maps a member id, logical id or pod.member to the session-prefix folder every reader uses, but only when the rig's recorded seats confirm it. An exact folder name, including a dotted one, keeps working as given. A name it can't confirm, because it's unknown or the seat list can't be read, still writes, with a note naming the likely folder, and nothing is refused. Earlier recap history is kept. The restore and continuity skills and the command reference now teach the same rule.
2026-11-10 14:36:39 -08:00
Mike Schwarz 89dcf739cc fix(gateway): a Slack 200 whose reply can't be read may have posted (#2909)
When Slack answers HTTP 200 but its body can't be read, the post may have landed, yet it was recorded as a transport failure, which pushed people toward a manual resend. Now that outcome is recorded as "may have posted": the durable record keeps it even after an earlier named failure, the open-asks report and the CLI show it as uncertain rather than "transport failed", and the replay checks for the post's marker before posting again. An update sent with --alert gets a clearer refusal: alerts mark asks, and updates are notices. Two limits are unchanged: a row that closes before the gateway's start replay isn't checked, and if the marker scan can't complete, the replay posts again with the visible may-repeat mark, so delivery stays at-least-once.
2026-11-10 13:31:11 -08:00
Mike Schwarz f4c12b73c4 docs(readme): link What's new, Features and Integrations (#2910)
The README's Links section pointed to the site, docs, blog, specification and videos, but not to the pages that say what OpenRig can do. It now adds three lines after Docs, in the existing format: What's new (every release, in plain words), Features, and Integrations (assistants, harnesses and environments, with setup and test status). Text only.
2026-11-10 13:23:57 -08:00
Mike Schwarz 410adcf4bc docs: v0.7.11 install pins (#2911)
The README, getting-started and quickstart install lines pinned v0.7.5, six releases behind the published one. They now point at v0.7.11: the npx skills add lines for Claude Code and Codex, the install script lines and the sentence naming the script's release, the getting-started install script, and the quickstart's example tag. Both pinned URLs exist at the tag. The same eight lines as the v0.7.5 pin commit; text only.
2026-11-10 13:10:57 -08:00
Mike Schwarz fde6d6c49f fix(handover): explain rebuild continuity and Codex launch choices (#2906)
A rebuild handover result now says what happened to continuity, the model and the permission envelope, instead of leaving an operator to infer it from constant flags. The result explains that the new conversation was primed from durable artifacts, reports the configured Codex model request (or the runtime configuration or default), and says the predecessor's in-pane model wasn't compared or carried. For a saved --codex-permissions envelope it describes the ordinary launch policy: workspace-write keeps OpenRig's Git metadata, seat-folder and pod-state writable roots, and read-only adds none. That's reported as policy, not as an observed difference or verified native enforcement, and an unreadable predecessor policy still rebuilds with its comparison reason kept. The reference documents continuityTransferred as a legacy constant and points to currentStatus.continuityOutcome, sourceOutcome and the provenance readback. Launch behaviour is unchanged. Refs #2856.
2026-11-10 13:01:06 -08:00
James B. Shields 3c2dee1b58 feat(cli): advise on unresolved queue create scope (#2901)
A queue create whose mission: or slice: tag doesn't resolve uniquely now gets advice, and the row is still created. The advice rides the create response's existing advisories array as {code, message} entries (unresolved_scope, ambiguous_scope, empty_scope, conflicting_scope, scope_check_unavailable, scope_check_skipped), so every client of the response sees it, and the CLI prints it on stderr. A check that couldn't run is reported apart from one that ran and didn't resolve. A cross-host create says the check was skipped and keeps any advice the remote returned. The committed row, the response's primary fields and the exit status are unchanged, and nothing new is refused. Refs #2725.
2026-11-10 12:52:37 -08:00
Mike Schwarz 25dcef874b docs(rig-spec): rig auth save reads CODEX_HOME, it takes no --codex-home (#2908)
docs/reference/rig-spec.md told readers preparing a Codex API-key login to run rig auth save --codex-home <home> <name>. rig auth save has no --codex-home option: it registers only --runtime and reads the Codex home from CODEX_HOME, so that line would be refused as an unknown option. The paragraph now says CODEX_HOME=<home> rig auth save <name>, the form the openrig-user skill already uses. "Never put a key in argv" is unchanged. Text only, one line.
2026-11-10 12:44:02 -08:00
Mike Schwarz cabd764a17 test(daemon): the resume-window cell decides on the read, not the runner's speed (#2907)
The codex-resume-prove cell "when the window ends, the attention text keeps the last reason a read gave" gave the proof a 40 ms window and a records read that took 15 ms of real time, so on a slow CI runner the window could end before any read gave its reason, and unrelated PRs went red. The cell now holds Date.now() and moves it past the window only when the second read runs. The first read gives no_cli_thread, the second returns a different reason (no_log_row), and the cell asserts the attention text keeps no_cli_thread after exactly two reads, so a value arriving after the window would fail it. Test only; production code is unchanged.
2026-11-10 12:42:27 -08:00
Mike Schwarz 4899766116 docs(rigs): fit a self-started team to the person's logins; review before commit (#2905)
A first run where only Claude Code is signed in could start the default starter team, whose reviewer runs on Codex, and stop at a Codex sign-in just when the review should start. The rigs skill (and its packaged copy) now gives an installing agent that starts a team itself, instead of through the operator, the operator's own fit check first: rig specs preview <team> --kind rig --json lists each seat's runtime; check those logins, or go by what the person said; if one is missing or the agent can't tell, it says so before launching and lets the person choose to adapt the team or sign in first. It never refuses. The starter lead's first move now reviews the uncommitted change before committing unless the person asks otherwise, and if the reviewer can't run, it tells the person and asks before committing without it. Text only.
2026-11-10 11:59:04 -08:00
Mike Schwarz ad6687a8bc fix(daemon): a restore notice to a person names the seat, not local paths (#2904)
When a seat's compaction restore can't be delivered and the seat has no delegates_to parent, the notice goes to a person, and such notices are often relayed to chat. It carried the restore-map path, the marker path and an evidenceRef to the map, none of which the person needs there. A notice to a person now names the seat and the stage, says what to do, and points to rig compact <seat> --state on the host for the details, with no local path and a null evidenceRef. The hand-restore route is in words: read the restore map from --state when this compaction has one, then --cancel, then ask the seat to restore from that map; if there's none, because the compaction skipped its map, from its restore-pending marker, or from its own transcript if the marker is missing. A notice to a resolved parent seat is unchanged, and any destination that isn't a resolved parent gets the person's form.

So that pointer is true, rig compact <seat> --state and rig seat status now show owedRestore.mapPath, the map the restore request uses, at every owed stage. Under --skip-map it names only a map the seat completed anyway, read from the persisted record, so it holds after the restore prompt and across a restart. The restore request and --state share one check, so they can't disagree. Read-only and additive; no new refusal or lifecycle action.

Refs #2845 (part a). Reported by @todd-dhango. The restore-map path itself holds a random attempt id rather than the Claude session id; leaving every local path out of a person's notice covers the concern either way.
2026-11-10 11:41:45 -08:00
Mike Schwarz 289c72075b docs(cli-reference): cite the pair-request handler's current lines (#2903)
docs/as-built/cli-reference.md cited packages/daemon/src/routes/hosts.ts:324-343 for POST /api/hosts/pair-request's pair_target_no_bearer and pair_human_required responses. That range went stale when the pairing-answer change moved the handler; it now cites hosts.ts:379-398, the handler through both responses. Text only.
2026-11-10 09:26:12 -08:00
Mike Schwarz 2870fa90cf fix(hosts): pairing answers reach an agent, and typed words never deny (#2900)
A person answers a host pairing request however they naturally would, a button or typed words in the TUI, Steering or Slack, and the agent that owns the request on this machine reads it and acts.

The owner is the kernel operator (operator-agent@kernel) when this host has a session for it and the queue takes rows to it. The request is then sourced from the operator, so a click or typed words come back to it through the ordinary reply path, with the speaker, the words verbatim or the option, and a link to the request, whose body tells the operator to run rig host approve|deny on a clear answer and to ask back when it's unclear. A received answer never settles the request, and typed words never deny it. A stopped operator stays the owner: the person's receipt says it may not have read the answer yet and names the terminal route, and nothing is granted, denied or started. With no kernel operator, or one that can't receive answers here, or when OpenRig couldn't tell, the request takes the manual route: Approve and Deny decide, and any typed reply is asked again with the person's words quoted, never read for yes or no. The text says which case applies.

rig host approve|deny <code or row> is a new one-shot decision route (POST /api/hosts/pair-decision), recorded as the seat or human@host. It sends the operator bearer (--bearer, else OPENRIG_AUTH_BEARER_TOKEN, else the terminal token), so it works from a plain terminal without a seat's environment, and a 401 says what to set. It refuses an unknown, ambiguous, expired or already-decided request (404/409), and a decision already made on the queue side (a Deny click, a refusing or approving close) can't be overturned. The token is handed over only for a recorded decision, an approving close or an agent-free click, once, bound to the exact request, before expiry, and without waiting on the outcome update. An empty or file-only reply (including the literal [file reply]) counts as a reply, not a refusal. At its deadline a request's ask closes expired even when the requester stopped polling, so a late click can't show approved; a daemon restart still drops open requests.

The TUI's answers now send the configured operator bearer (the CLI's operatorAuthHeaders) instead of only the terminal token, so a person's TUI answer reaches a pairing target, which must run with OPENRIG_AUTH_BEARER_TOKEN set, without writing a terminal-token file. Every other TUI call keeps the terminal credential, the daemon's checks are unchanged, and a 401 on an answer says what to set without printing a token.

The person sees a receipt, any clarification from the operator, and the outcome as separate updates. rig host approve|deny sends its outcome itself; receipts, re-asks, queue-derived outcomes and expiry updates ride the requester's poll. Requests live in memory, as before.
2026-11-10 08:37:48 -08:00
billbenetti 5b6702add4 fix(daemon): trust the canonical git root in the Claude pre-launch (#2820) (#2899)
The Claude pre-launch bootstrap now also trusts the canonical git root that Claude Code's strict trust check reads. Before, workspaceTrustKeys seeded only the seat's cwd and its realpath, which satisfies the loose check but not the gated-grants backstop that Claude Code 2.1.319 and later apply when a tracked .claude/settings.json carries permissions.allow. So rig seat launch --fresh for a seat below its git root stopped at "Quick safety check" with the focus on "No, continue without these permissions".

claudeStrictTrustRoot (packages/daemon/src/domain/claude-git-trust-root.ts) follows Claude Code's own lookup without spawning git: from the resolved cwd to the nearest folder holding .git; a linked worktree maps to its main checkout only when the worktree's back-link matches; a bare repository's common dir is its own key; a separate git dir or a submodule keeps its own root; outside git nothing is added. It never trusts an ancestor above the root, keeps existing projects and fields, and doesn't rewrite an already-trusted file. Any read failure falls back to the folder's own root, and nothing on the launch path can throw.

Refs #2820: this is item 1 of the report. Item 2, the provider-prompt auto-driver pressing Enter on the backstop variant, stays open. Thanks to @billbenetti for the fix to their own report, and for the captured 2.1.319 fixture and the before-and-after table across 2.1.319 and 2.1.322.
2026-11-10 08:23:27 -08:00
Duc faa3e4917e fix(cli): no rig send reply hint for a sender that has no session (#2898)
A queue message signed with a label whose sender has no session (a routine's <sender>@<rig>, say) ended with "Reply: rig send <sender> ...", which fails because there is no session to send to. The daemon now checks the sender the way rig send resolves a target, and when that finds no session it replaces the hint with a short no-session note. Seat, person and OpenRig senders keep their exact footers; when the check can't tell, or errors, the existing hint stays; nothing new refuses, holds or drops a delivery. Known limits: a direct CLI send has no lookup yet, three-part remote labels and stopped sessions keep the hint, and the note doesn't prove reachability elsewhere. Refs #2895 (part a; part b was #2897). Thanks to @vanducng for the report and both fixes.
2026-11-10 06:40:11 -08:00
Duc c38d1183cb fix(cli): skill loadout --seat exits 0 with its note on a runtime without seat skills (#2897)
rig skill loadout --seat on a seat whose runtime has no seat skills (pi-tui, say) exited 1 for what is an informational note. The daemon's seat read now answers 200 with unsupported: true, the note, no storage and no skills, and the CLI prints the note and exits 0, in text and JSON. Applying skills to such a seat, and an unknown session, still return their errors. A newer CLI against an older daemon keeps the old error, and an older CLI against a newer daemon can show the old apply hint, whose write still refuses. Refs #2895 (part b). Thanks to @vanducng for the report and the fix.
2026-11-10 06:08:58 -08:00
Dery Ferdika fa2a2bdbdb ci(workflows): stop persisting checkout credentials in the portability report (#2894)
The portability report workflow's actions/checkout step now sets persist-credentials: false, as the six checkouts in tests.yml already do, so the job's GITHUB_TOKEN isn't left in the workspace git config. The job only runs scripts/portability-report.mjs, which reads the local diff between the pull request's base and head and writes the step summary; it never fetches, pushes or makes an authenticated request, and fetch-depth: 0 still gives it both commits. The report's output is unchanged. Thanks to @DeryFerd for the change.
2026-11-10 05:07:42 -08:00
Mike Schwarz ed16e0d289 docs(cli-reference): say how a pairing is approved on an older target (#2893)
Follows #2890. The command reference's pairing paragraph now opens with how a person approves on a 0.7.11 or later target (answer the request's Approve or Deny question in the TUI, on Steering or with Slack's buttons; an agent seat adds --resolution approve), and a separate paragraph covers a 0.7.10 or earlier target: it asks no question, its person approves with the request's Approve (the TUI from 0.7.1, or Steering), and every close that leaves the request done approves it, whatever its closure reason, including a typed reply or an agent seat's --state done --closure-reason denied. Only expiry or a denied, canceled or failed state refuses there. Text only.
2026-11-10 04:01:50 -08:00
Mike Schwarz c7f841537e fix(cli): a seat waiting on a person names its question, not an older error (#2891)
Follows #2888. A seat with a live question and an older recorded error was shown as "waiting on a person: <the error>", because the reason took the startup error first. The attention line in rig terminal open, rig status and the kernel start, and rig ps --nodes' REASON column, now give the served question's reason when the daemon flags a live question, with the daemon's own precedence (its arbitrated needs-input answer, and the activity hook only without one), shown whole, or "reason unavailable". The "needs attention" order is unchanged, and so is REASON on an older daemon that doesn't send the flag. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 03:41:16 -08:00
Mike Schwarz f18f492d8d docs(rigs): move a Codex seat its account can't run to Claude Code (#2887)
A Codex sign-in can work while the account can't run the model a seat was given ("not supported when using Codex with a ChatGPT account"), so the seat answers every turn with an error. The rigs skill and the kernel operator's guidance now tell the person which seat and model failed and offer the way out: if Claude Code is signed in, move just that seat with rig seat set-runtime <seat> --runtime claude-code (--dry-run first); otherwise give it a model the account can run with rig seat set-model, then start it again with rig seat launch <seat> --fresh --stop and check the model it reports. Either way the seat starts a new conversation without the earlier handoff, so the installing agent sends it again: the goal, folder, branch, constraints and declined choices. getting-started's incomplete-setup table gains a row for the model error. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 03:16:43 -08:00
Mike Schwarz d379d59cc2 fix(daemon): a typed reply never approves a host pairing (#2890)
A typed reply on a host pairing request closed it as approved, so a person who typed "no" in the TUI, Steering or a Slack thread still handed the requester this daemon's token. The request now carries one question, Approve or Deny. The requester's poll approves only on a recorded Approve choice (the TUI's act, Steering's question card, Slack's Approve button) or on an agent seat's close with rig queue update <id> --state done --closure-reason no-follow-on --resolution approve. Any other answer or close denies, including a typed reply (its words are kept on the request), a close without --resolution approve, and a bare approve with no choice. Pairing answers complete in the request itself, as runner permission asks do, so no reply row is created and a target with no kernel rig closes them cleanly; other asks are unchanged. rig host pair's printed lines and the command reference say this applies to 0.7.11 and later targets; on an older target a typed reply still approves. Limit: a Slack reply that is only an attachment, or the literal text [file reply], keeps no words in OpenRig; it still denies, and the original stays in Slack.
2026-11-10 02:52:09 -08:00
Mike Schwarz b21aba8644 fix(cli): name a seat waiting on a person in terminal open and the kernel start (#2888)
On a new machine the kernel's advisor sat at a settings warning and an expired sign-in, and rig terminal open said nothing about it; the daemon already flagged the seat as needing attention. rig terminal open (as notes), rig status (under Kernel when it isn't ready) and rig daemon start --wait-for-kernel now name each seat the daemon flags, with its recorded reason. "Is waiting on a person" is used only when the daemon serves a live question, with its own precedence: the arbitrated needs-input answer, and the activity hook only without one. When the check can't be made (an unreadable rig list or node read, a seat outside this daemon's inventory, a tile on another host, or an older daemon that doesn't say which host each tile is on), they say "Couldn't check" for those seats instead of assuming. Nothing new is fetched from another host, no command's outcome or exit code changes, and getting-started's incomplete-setup table explains the three messages. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 01:47:28 -08:00
billbenetti 8650b33bb8 fix(daemon): stop counting daemon bookkeeping as ceremony coordination (#2762) (#2889)
process.ceremony-amplification counted every transition in a handoff family's window, including the watchdog's park and parked-owner wakes, queue@system wake attempts, and the daemon's auto-unparked and blocker-custody notes. So coordinators tracking a real chain crossed the threshold on bookkeeping. The count now leaves out transitions by a system actor (any *@system, or the bare system) and, under any actor, notes that begin with a daemon bookkeeping prefix (auto-unparked:, blocker custody moved, park wake, parked-owner). A seat's note that only quotes one of these mid-text still counts. The same filtered set drives the count, segmentation, the basis and the reported transition ids, and the threshold is unchanged. After upgrading, an affected family asks once for a re-assessment, because its basis changed. The regression tests are built from the reporter's three captured families, with the seats' notes redacted. Thanks to @billbenetti for the report and the fix.
2026-11-10 01:20:57 -08:00
Mike Schwarz 06aee3e79c fix(installer): report incoming PATH gaps with shell hints (#2883)
The installer added the npm global prefix to PATH only for its own run, so an install could finish while rig wasn't on the person's shell PATH. At the end, and also when rig setup stops early, it now checks the PATH it was started with. If that finds the installed rig, it says so; a symlinked prefix or executable counts, because both paths are compared after resolving links, and a failed comparison falls back to the advice rather than claiming success. Otherwise it says what it found, prints a paste-safe line for the person's shell (fish, or sh, bash, zsh and other POSIX shells), and asks them to check with command -v rig in their own shell. It never edits a shell profile and adds no refusal. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 01:18:25 -08:00
Samuel 68307458fc docs(reference): list the community-maintained nodecode runner (#2859)
Adds the community-maintained nodecode runner to the community harness presets list: how its installer sets up the preset, what it reports to OpenRig, and its limits. Its connector labels each permission choice by meaning (allow or reject, once or always), and a question it can't classify, or one with two choices of the same kind, stays in the pane; only the first four choices reach OpenRig. The installed preset uses its own seat profile, and after a failed, cancelled or interrupted turn it moves the conversation back to the last completed turn before taking input (files that turn changed aren't restored). The test results are the author's own, on OpenRig main at 7078c754d without fork-only commits. Thanks to @smota for the entry and the connector changes behind it.
2026-11-10 01:11:18 -08:00
Mike Schwarz 87a91c542a fix(hosts): a pairing request names how its person approves it (#2886)
rig host pair and the target's approval item told a person to run rig queue update <id> --state done --closure-reason no-follow-on. That command acts as a managed agent seat, so from a plain terminal it refused with "OPENRIG_SESSION_NAME is not set". Both now name the person's way first: in the TUI (rig tui, then attention), open the pairing request and choose Approve; Steering's Approve does the same. The rig queue update form stays, named as the way from an agent seat on the target. The approval item says a person refuses by letting the request expire (nothing is persisted). rig queue update's no-seat refusal adds one sentence pointing a person who answers an ask to the TUI or Steering, and the command reference says the same beside rig host pair. No new approval path, and who can approve is unchanged. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 01:04:43 -08:00
Mike Schwarz 1a2bbad8a5 fix(tui): show a health source's evaluation cap as coverage, not as degraded health (#2885)
The TUI Health view showed a yellow PARTIAL whenever a health source was evaluated only up to its cap (for example "evaluated 200 of 224 handoff families"), so a busy, healthy team looked permanently degraded. A capped source now shows COVERAGE evaluated/total in the info colour; a source that's unavailable, indeterminate or needs attention keeps its warning, and an empty scoped result still reads "no findings served; not a healthy verdict". Known limit, unchanged: when the cap and result truncation coincide, a scoped view with no records in the first results can't tell whether findings were omitted; it says it isn't a healthy verdict. Reported by @bramscher in D#52 (https://github.com/mvschwarz/openrig/discussions/52#discussioncomment-19510164).
2026-11-10 00:26:47 -08:00
Mike Schwarz 09b11a369f fix(hosts): denying a pairing request never hands over the token (#2884)
A host pairing request (rig host pair) is approved on the target by closing its approval item. The pairing poll, GET /api/hosts/pair-request/:pairId, read any close to done as approval and handed the requester the target daemon's bearer token. Steering's Deny and Drop close the item done with closure reason denied or canceled, so denying a pairing request in Steering completed the pairing and gave the requester full API access. Now only a done close with closure reason no-follow-on (Steering's and the TUI's Approve, or an approval from the CLI) approves; every other done closure, and denied, canceled or failed, answers denied and hands nothing over, and the request is consumed. Three cells cover approve, deny and drop; they fail on the old code. Found while working on the pairing-approval report in D#52.
2026-11-10 00:20:45 -08:00
Mike Schwarz d5832cd90d feat(harness): community harnesses can declare protocol: pi-rpc (finishes #2322) (#2870)
A community harness preset can declare protocol: pi-rpc. The seat then runs OpenRig's Pi runner with the preset's command and its own runtime name, so its identity, exit and resume type are its own (community:<name>), never Pi's. protocol: pi-rpc can't be combined with runner, launch_result, names_conversation, startup_assisted or first_input, and resume stays an explicit declaration. Resume requests the exact session file with --session; a runner that comes up on another file is refused, never posts the other file's identity, and raises runtime_error attention, so the seat keeps its requested session and stops reading idle. A fork that reports no session file raises attention instead of reading ready, and a pi-rpc seat's runtime_error reads as needing input. The launch records agent_runner = 2 for this hosted protocol runner beside 1 for a preset's own runner and 0 for a plain preset; the readers that checked = 1 now accept either runner value, and only the /hooks identity path and hookEventState read 2. That identity path follows the launch record when one exists, reads the installed preset only before a record lands, and takes a post only when its reported runtime matches the seat's, so a helper process inside the seat can't replace the seat's resume token. Only Pi seats resolve an account home, as on main. The merge with main keeps the streaming working beat, accepted turns and turn-start opening for Pi-protocol forks, the entry cursor's OMP-only id rule, the per-session state root and guidance-block records, and launch_wrapper beside protocol. The runner contract names an OpenRig-hosted protocol runner as a third preset shape, and the presets reference documents pi-rpc and that a preset without resume is launch-only: a restore that would resume its saved conversation waits for a fresh-start decision. Built from @shravansumanthanan's #2322, whose three commits are kept with their authorship.
2026-11-09 22:22:55 -08:00
Mike Schwarz 06c05d6ba3 fix(cli): point external routine note hint to row host (#2882)
When rig queue update --source <name>@external is pointed at a --host that resolves to another host, the refusal now ends "Run the routine on the host that holds the row. Nothing was sent." It used to say to append the note on the routine's own host, where the row may not exist. Text only: the refusal, its conditions and every other path are unchanged, and the --source help already limits this mode to the local daemon. Follows #2877, which added the external note path for #2866.
2026-11-09 21:33:27 -08:00
billbenetti e935533db1 fix(cli): rig queue update --source <name>@external records a routine's note as an external actor (#2866) (#2877)
rig queue update <id> --source <name>@external --note "..." lets a routine outside any managed seat append a note. The note is recorded with that external actor (claimed:v1) through the update route's existing body actor, never as a seat, and no seat header is sent. It's note only: any flag that changes state, owner or routing is refused with --source. Inside a managed seat the seat stays the actor. The check decides on the resolved target, so --host local and the daemon's own host id take the local path, and a --host that resolves to another host is refused with the limit named in the error and the help. Without a seat or --source, the error now points to --source <name>@external. The help says a note resets the row's stall and notification timing and advances a claimed row's last activity, but never resets how long an unclaimed row has waited. Reported in #2866 and built by @billbenetti.
2026-11-09 20:31:23 -08:00
Mike Schwarz a34c256144 fix(execution): on the default flow the ladder shows each slice's recorded status and says when nothing records a build (#2872)
On the default flow, rig view show execution and GET /api/views/execution now show q4_ladder[].recorded_status, { word, bucket, basis }: the status: word from the slice's own node file and the bucket it falls in, as the team's own record that never turns a rung true or makes a slice next-up; with no status, word and bucket are null. q4_records says once for the mission which records exist, and when none records a build, that built, reviewed and merged read INDETERMINATE because they aren't recorded, which is not the same as not done. Plan lock still reads false, and its basis now says plan lock is a Part B step. The slice indexer's status table is exported as sliceStatusBucket, unchanged. The TUI's mission view uses the served bucket, so shipped-to, closed and promoted slices count as declared done instead of planned (an older daemon keeps the old rule), the slice strip's next says none when a slice is declared done, and the evidence page shows the mission's records line. sdlc-conventions.md gains one Part A paragraph on what the view shows, where the status word comes from, and that built means a Git commit. Reported by @ziadeh in #2761, which this refers to.
2026-11-09 20:21:17 -08:00
Mike Schwarz c531d48df6 fix(handover): a rebuilt seat's briefing lists older recaps as optional history (#2876)
rig seat handover --source rebuild now briefs the successor with the current recap, LEARNED.md and the latest restore packet under "Priming artifacts (read each, in order):", and lists superseded recaps newest first under "Older recaps, newest first (read these only if you need the seat's history):". The chain builder marks each superseded recap optional: true; order and addresses are unchanged. The recorded primedArtifacts, the seat.handover_completed event and the JSON result keep every artifact and gain the additive optional field. Retention, the recap store, the missing-artifact gaps and the empty-chain line are unchanged. The seat-continuity-and-handover skill says the same, and its digest is regenerated. Found from a report on a fork.
2026-11-09 19:47:13 -08:00
Mike Schwarz 7f7dc9720c fix(queue): rig queue show names a handed-off row's successor (#2874)
GET /api/queue/:id, and with it rig queue show and the MCP show tool for unscoped callers, now names a handed-off item's successors: the rows whose handedOffFrom is this item, plus a local row its closure target names, each with its id, destination, state and how it was found. When none is named it adds a note: a cross-host target as recorded, with no remote lookup; that no row on this daemon was made by the handoff; or that the lookup couldn't be done, and the read still answers. Past 20 linked successors the newest 20 are named and successorsTruncated is set. Callers with a scoped MCP grant don't receive successors, successorsTruncated or successorNote; the existing scope-aware singular successor is unchanged. Nothing stored changes; closure_target keeps recording the new owner as documented. Migration 159 adds a partial index on handed_off_from for the lookup. Reported by @XBS9 in #2794, which this refers to.
2026-11-09 18:56:06 -08:00
Mike Schwarz be1b910a4b Let the active terminal size writable agent tiles (#2868)
Writable terminal tiles use the published 0.7.10 plain tmux attach again, replacing the captured view that only attached while someone typed: the active terminal sizes the agent's PTY under tmux's own client-sizing policy, so text reflows on every resize and switching devices follows the screen in use. Read-only tiles keep their observer behaviour. Readable-capture, layout and browser pins release by unsetting the window's local window-size instead of writing back a remembered value, and a failed or unknown pin record leaves no manual pin behind. Browser fixed geometry is now the explicit setting terminal.fixed_geometry (default false), and an existing manual window without an OpenRig pin record is left untouched. Writable viewers kept from 0.7.8, 0.7.9 or the earlier 0.7.11 candidate are refreshed by closing only the owned viewer pane and reopening the view (a following view needs --no-follow first); the agent keeps running and nothing is migrated silently. Existing 0.7.10 sizing clients and read-only viewers are unchanged. As in 0.7.10, Enter or Ctrl-C typed while a new tile is still attaching can close that viewer. The writable script has no apostrophes or control characters, so Herdr keeps its restart command.
2026-11-09 18:36:51 -08:00
Mike Schwarz f4448fe901 feat(proof): a proof policy can require every listed judge, and readiness shows each judge's own judgment (#2867)
A proof policy that lists two judges read ready as soon as one judge accepted, and a later accept from one judge hid the other judge's rejection, because readiness followed the item's latest authorized receipt. That stays the default for existing policies, byte for byte: the policy revision, item revisions, readiness revision, state and reasons are unchanged on upgrade. A policy can now opt in with proofPolicy.everyJudge: true, which binds a new policy revision (old receipts read unknown until judged again) and requires every listed judge's own current acceptance; the item state is the worst per-judge state (rejected, unknown, withdrawn, pending, accepted) and its reason names the judge. In both modes each item carries judges[], every listed judge's latest receipt and whether it decides, shown by rig proof show, its JSON and the TUI provenance and slice proof views, without presenting the latest receipt as the policy's verdict. The correction chain, compare-and-swap, replays and --replace are unchanged, and addresses are not canonicalized. rig proof --help and sdlc-conventions explain the two modes. Reported by @amsayfi in #2765, which this refers to; the reporter's two-judge policy needs everyJudge: true.
2026-11-09 16:58:26 -08:00
Mike Schwarz 24dd3eb888 fix(queue): a hosted member that polls for its work gets one standing notice, not a finding per item (#2864)
A hosted member with no wake trigger picks up its work by reading the queue, so every item addressed to it got an undelivered-wake finding as soon as the stuck sweep ran. The sweep now skips that per-item finding for a roster hosted member with no trigger whose wake was recorded unroutable, and lists the rows it leaves unclaimed past the unclaimed age in one standing notice per member, extending the per-seat unclaimed-backlog notice (refreshed in place, closed on pickup). Each row's age runs from its own time; a check-in never resets it. The notice says whether the member is checking in, has not been observed checking in since a time, has never been observed, or is unknown. It goes to the person, else the rows' shared creator, else the operator agent, else the member. A notice addressed to a creator lists only rows that creator sent: when another creator's row would join, one notice for every row is filed where a new notice would go, and the old notice is closed only after that create succeeds; a failed create leaves the old notice unchanged for the next pass. Migration 158 adds queue_inbox_checkins: GET /api/queue/list records a check-in, at most once a minute per session, when the caller lists its own active inbox. Another actor's read, a read of only what the caller sent, and a follow-through read of a row it handed on are not check-ins; a follow-through read counts only when it returned a pending row addressed to the caller. A failed write is logged once and never fails the read. Triggered hosted members, unrostered unroutable addresses, declared routes, human-notice failures, claimed-but-stalled rows and local seats are unchanged. No new settings, refusals or error codes.
2026-11-09 16:37:50 -08:00
Mike Schwarz a11a78ebb5 fix(queue): preserve prompt escalation custody through handoff (#2865)
The wake ladder treated a handed-off prompt escalation as a final disposition, so its successor (same episode tag) was retired as "prompt cleared or original episode ended" while the seat's prompt still stood, closing the owner's blocked hold and its wake. A handoff is now custody: a handed-off alert stays non-final while its local successor carries the alert tag, and is final otherwise, as before. An older automatic retirement without a clear marker can re-escalate a still-blocked reminder; genuine clear markers and manual dispositions stay final. The positive-clear marker is recorded apart from custody, holds included, so a later refusal of the same watchdog after an owner close escalates anew. An owner's explicit hold and wake survive clear and ended ticks with no new dispatch under that hold. The retirement note names its one cause.
2026-11-09 15:33:37 -08:00
w4rc0n 1011e7472f feat(gateway): add pull-mode relay outbound with outbox list and ack (#2742) (#2844)
Adds an optional pull mode to the local gateway relay, for clients that cannot run a push listener (#2742, by @w4rc0n). A relay profile with OPENRIG_RELAY_MODE=pull is ready with an inbound token only. GET /api/gateway/relay/outbox lists complete frozen envelopes the transport staged for that connector (authenticated, direct loopback, limit 1-200, a read changes nothing); POST /api/gateway/relay/outbox/ack accepts any listed deliveryId and records the same acceptance receipts, guards and drain as a push 2xx, completing a partial receipt set on retry. Duplicate and unknown acks are answered from the delivery seen stores. A pull notice stays quiet only while the outbox would list it, and reads never-posted at 120 s otherwise or after 24 hours if never acknowledged. Push replay completes a partial receipt set only from this delivery's own receipts. Relay profiles load once per delivery-outcome batch. No broker, leases, read receipts or polling requirement.
2026-11-09 12:06:44 -08:00
Mike Schwarz 5e333e0179 fix(mcp): discover teams within an external grant (#2855)
An external grant caller with team/list could not learn its own rig: rig_whoami is not offered to external callers and rig_team required rigId, so connected assistants stalled asking the person for the rig name. rig_team now treats rigId as optional for an external caller: the grant's one distinct rig name selects that rig through the existing scope resolution, several names return only the grant's own choices, and an empty or unresolved scope is named plainly. Explicit rigId calls, out-of-scope errors and other callers are unchanged. The tool description and instructions name the discovery in their first paragraph, and hosted-members.md is updated.
2026-11-09 11:54:35 -08:00
Mike Schwarz 3d1f2f9bee fix(daemon): preserve Claude launch during token switches (#2853)
A token-only Claude provider switch relaunched a seat by rebuilding its options, so a member CLAUDE_CONFIG_DIR or a custom --settings file added to the running launch could be lost without a warning. The switch now reads the running Claude command line (bounded) before the stop, re-checks the process identity, and carries the mapped options and CLAUDE_CONFIG_DIR through the existing private launch channel on both managed and classic resume paths. Other native environment values are compared in memory with the kept tmux session and reported by name only; none is written to launch scripts, process arguments, snapshots or results. The seat's saved permission posture replaces the running one and effort precedence is unchanged; the running model is kept and a differing saved model is named. An unreadable capture or unmappable option continues with the ordinary launch and a warning. Other restore and account-home paths do not capture.
2026-11-09 11:25:18 -08:00
Mike Schwarz b7e03daaa0 fix(tmux): keep daemon credentials out of the server environment (#2850)
The first daemon tmux client seeds the tmux server's global environment, so a server the daemon started held its own credentials and passed them to every later pane. The argv, shell, legacy and control tmux clients now start with a copied environment that leaves out OPENRIG_ACTIVITY_HOOK_TOKEN, RIGGED_ACTIVITY_HOOK_TOKEN, OPENRIG_AUTH_BEARER_TOKEN, OPENRIG_TERMINAL_BEARER_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN and OPENRIG_RELAY_OUTBOUND_TOKEN; provider keys and user configuration are unchanged. Managed seats keep their resolved terminal and operator bearers through the existing private per-session environment (launchSessionEnv), shared by launch, handover and reconstruction onto a fresh server; unset bearers are omitted and authentication stays on. A server that is already running is not scrubbed. Reported in #2748 by @pr0xc3nt4ur1.
2026-11-09 11:18:19 -08:00
Mike Schwarz 0e49fb87f3 fix(queue): the first target named in a custody-verified note verifies it, and the finding prints each target's note (#2841) (#2852)
The stuck sweep accepted a custody-verified note only when its first word was the closure target, so prose notes that named the successor later kept valid hand-backs reported as unverified. Per note, the first word that names one of the row targets, exactly or as a comma list of them, is now what the note verifies; later mentions do not count, so a note that mentions B only as still pending keeps B finding. The finding prints the exact note for each target and states the rule. There is no negation parsing: the first target-naming word counts even if the prose around it says unverified. The issue third ask, a one-way custody receipt for a return-only cross-host channel, is not included. Refs #2841, reported by @todd-dhango.
2026-11-09 10:45:58 -08:00
Mike Schwarz fcf0c0fe5d fix(queue): a seat with several delegates_to parents sends its findings to its own fallback or its escalates_to (#2840) (#2851)
With several delegates_to parents, the stall findings and wake escalations of a seat went to whichever parent session started most recently, often a seat that could not act on the row. resolveEscalationOwner now treats several parents as ambiguous: a declared escalates_to decides; without one the owner is null, so each caller takes its existing fallback (unclaimed and undelivered-wake findings to the row destination, pickup findings to the stalled seat, unconsumed-wait and parked-owner findings to the row creator, the wake ladder to its operator rung), and the sweep and ladder recovery rows carry the reason. One parent, including one with no session, is unchanged. The compaction restore-failure notice and the rig-edge display still use the newest-parent pick. Reported in #2840 by @todd-dhango.
2026-11-09 10:17:35 -08:00
w4rc0n b8d93cb64b feat(terminal): a herdr-app provider behind TerminalProvider (#2744)
A herdr-app provider beside herdr, cmux and rex behind TerminalProvider. rig terminal open <view> --provider herdr-app (or a plain open inside a herdr-app terminal while that window answers) opens the view as a Rig view in the herdr-app window: an equal grid of the view's seats with an <agent> · <slice> title row per cell, on one atomic layout write, drawn by four small mods that an open installs or updates. An interactive local viewer runs its seat's read-only tile until herdr-app acknowledges the layout that shows it; then a per-viewer gate swaps in the writable tile and drops anything that reached the terminal before. Read-only and remote viewers run as composed. Close removes the view's cells from the layout first and ends only OpenRig's own viewers once the window acknowledges; seats are never stopped. Following isn't claimed for this provider, and herdr-app on a different machine from the daemon's herdr-core isn't supported yet (docs/reference/herdr-app.md).
2026-11-09 10:15:06 -08:00
Mike Schwarz 1f924f9d7a fix(terminal): confirm Herdr grids with omitted filler labels (#2849)
Herdr 0.7.5 omits the optional label field from pane.list for a pane created with an empty label, so a Rig view that needed blank filler panes (for example 7 seats in a 3-by-3 grid) reported every seat pane_unconfirmed. exitedSeats now treats an omitted label as blank, through the existing blank-filler rule; a present label that is not a string still leaves the page unconfirmed. No new request, wait or dependency. Reported in #2747 by @pr0xc3nt4ur1, whose suggested fix this is.
2026-11-09 09:47:58 -08:00
Mike Schwarz 2363db4c5c Preserve Pi typed edit order on affected Node readline versions (#2847)
On Node versions with readline's chunked-insert bug (22.21.0-22.22.0, 24.9.0-24.11.1 and 25.0.0-25.1.0; the upstream change nodejs/node#60470 is in 22.22.1, 24.12.0 and 25.2.0), createRunnerInput keeps Node's own key parser and writes each run of ordinary decoded text between controls to the editor once, so cursor-edited input submits in order. On fixed Node the original path is kept, because grouping there would change undo. On affected versions a run of typed text is one undo step. Bracketed paste, the 1 MiB bound and submit routing are unchanged; unknown escapes go to Node as before. Refs #2834 (reported by @oodadoudou, who also measured the per-character approach).
2026-11-09 09:26:37 -08:00
Mike Schwarz e9682010b8 fix(daemon): queue erasure ledger replays only committed batches (#2843)
A ledger line is now proof of a committed erasure. Each batch's entry lines carry a batch id; a queue_erasure_batches row (migration 157) is written in the batch's own transaction; a commit line follows the commit and an abort line follows an in-process rollback. Reconciliation writes missing commit lines from the batch records (line_at), never aborts, and never reads the ledger file. An ids replay applies committed batches only; a batch with neither a line nor a record is reported as ledger-unresolved and never replayed, so a restore never erases work that only a rolled-back batch named. Routine and members runs list unresolved batches but don't count them against clean. A newline guard keeps a partially written line from fusing with the next.
2026-11-09 07:19:09 -08:00
JUSHUANGHUI LI c4bc5553ba fix(proof): ignore checkboxes inside fenced examples (#2836)
The CLI and daemon logical-checkbox parsers now skip checkboxes inside top-level fenced code blocks (backticks or tildes, 0-3 leading spaces, a same-type closer of sufficient length), following CommonMark, while keeping source positions and continuation lines for the rest. Known limits, named in the description: an unclosed fence runs to the end of the file as CommonMark says, which hides later checkboxes without a diagnostic, so readiness can still read ready; indented code, nested containers and the section extractors are not fence-aware. This is not general Markdown support.
2026-11-09 06:39:36 -08:00
Mike Schwarz 9e004f2a8f feat(daemon): erase queue content for closed-work retention and account deletion (#2837)
Adds rig queue erase (exactly one scope: --members, --ids or --routine, plus --rig for an account rig's own records; a dry run unless --apply) and two opt-in retention settings for closed work (retention.closed_content_days and retention.closed_metadata_days, default 0). A scrub keeps the row as the replay barrier; a delete keeps only the id, and the create path refuses an erased id with qitem_erased. Each batch rechecks its rows, endpoints and absent ids inside the transaction that changes them, writes its ledger lines first when retention.erasure_ledger is set, and erases only copies whose sessions belong to the owner; mixed copies, unreadable events, changed rows and unverified rigs are kept and reported, and the run is not clean. The events pass walks in windows with yields. Migrations 155 and 156. Docs: docs/reference/queue-erasure.md.
2026-11-09 06:17:28 -08:00
Seán McNamara 3518c03b73 fix(queue): wake a seat's unroutable items as soon as it is reachable (#2790)
When a row is created for a seat before its pane is bound (for example a Slack reply recovered at boot), its wake fails as unroutable and was never retried. Now, when the seat becomes reachable, the stuck sweep and a startup drain re-send that wake once: an eligible, positively unsent wake staged before any pane existed may take its first live binding on the same node and session, and the real delivery guard still checks it. Each correlated peer must itself be a failed, pending, seat-addressed intent to be included, so indeterminate or otherwise-failed peers keep their state. Delivered, indeterminate, previously bound and handed-off work is never replayed by this path. The unroutable: prefix is matched exactly, as #2824 made it.
2026-11-09 06:01:52 -08:00
Mike Schwarz a43e09c1b9 Read handover recap transcripts from the end in bounded chunks (#2838)
parseJsonlExchanges read the whole transcript with readFileSync(path, "utf8"), which fails above about 512 MiB (the string limit), so a long-lived seat's successor got no predecessor recap. It now reads fixed 64 KiB positional chunks from the end, joins byte fragments before decoding UTF-8, and stops at the n visible exchanges it needs, keeping #2832's newest-first decoding. Output is the same as before for every file that could be read, including CRLF, corrupt and long lines and fewer than n exchanges; a read error still gives the existing "unavailable" report rather than a shorter recap. Builds on #2832.
2026-11-09 05:58:20 -08:00
JUSHUANGHUI LI 6bc3186975 test(daemon): quote harness fixture paths containing spaces (#2830)
Test-only. claude-context-collector.test.ts compares the collector's argv literally, and community-harness-runtime.integration.test.ts and runner-conformance-check.test.ts quote the fixture paths they place in shell text and YAML, so these tests pass from a checkout path containing spaces or #. Production code is unchanged: the paths the product builds are already quoted, and a preset's command or a conformance --runner is the author's own shell text to quote.
2026-11-09 05:31:43 -08:00
JUSHUANGHUI LI 00d6d25cce fix(scripts): run repository guards from paths containing spaces (#2828)
scripts/check-docs-guard.mjs, check-internal-leak-guard.mjs and mirror-skills.mjs decided whether they were the main module by comparing import.meta.url with argv[1] as written, so from a checkout path containing spaces, # or a symlink they silently exited 0 without checking anything. They now compare the resolved real paths of both sides, handle an absent argv[1], and still don't run when imported. A new script-entrypoints test runs each script from such paths, including through a symlink under --preserve-symlinks-main.
2026-11-09 05:31:15 -08:00
billbenetti 10736e1d9c perf(daemon): read queue listings, show and the parked route without per-row re-reads (#2825)
Queue listings, queue show and the parked-obligation route no longer re-read each row: the listing reads are batched, the parked route is a lean read of only what it shows, and both share one row projection (QueueRepository.projectRows and the shared waiting reads). Rows, values, key order, delivery outcome and failure detail are unchanged. Activity observation is not frozen: a listing still reads each owner's activity once, and that read can settle an expiry. Rebased on its own after #2824; part of the #161 queue-performance work.
2026-11-09 05:09:52 -08:00
JUSHUANGHUI LI 1fe9296b48 fix(proof): read retained judgments with CRLF line endings (#2835)
The retained judgment ledger's frontmatter delimiters required bare LF, so an otherwise identical judgment saved with CRLF line endings couldn't be read. They now accept an optional carriage return before each delimiter's newline. Nothing is normalised: the content hash is still over the parsed payload, field, sequence and lineage validation are unchanged, and whole-file, raw and addressed-span evidence hashing still reports changed bytes.
2026-11-09 05:07:12 -08:00
JUSHUANGHUI LI 369a129f69 perf(daemon): resolve Markdown section ends in one pass (#2833)
parseMarkdownSections found each section's end with headers.slice(idx + 1).find(...), which copies the rest of the heading list for every section, quadratic in heading count. It now walks the headings once from the end with a stack of later headings, popping those deeper than the current one, so the top is the nearest following heading with the same or a shallower level, exactly the heading the old find returned. Section recognition, addresses and materialisation are unchanged; the new cells cover nesting and a 10,000-section document.
2026-11-09 04:36:52 -08:00
JUSHUANGHUI LI b6e1604710 perf(daemon): decode only the exchanges needed for a recap (#2832)
parseJsonlExchanges, used for a handover's predecessor recap, now walks the transcript's lines from the end and JSON-decodes only until it has the n visible exchanges it needs, then returns them in chronological order. The output is the same as before for every input, including CRLF, corrupt, blank and metadata-only lines and files with fewer than n exchanges. The whole file is still read into memory, so this saves decoding work, not I/O or memory.
2026-11-09 04:34:35 -08:00
JUSHUANGHUI LI 977d0ede41 fix(daemon): warn about out-of-range time zone offsets (#2831)
The time-bound advisory (isRecognisedDateOrTime) already range-checks month, day, hour, minute and second; the zone offset was the one field it didn't. It now accepts offset hours 00-23 and minutes 00-59 (RFC 3339's time-numoffset), so +24:00, -01:60 and +99:99 produce the advisory while -00:00 and +23:59 stay recognised. It stays advisory: the bundle and telemetry routes return their usual results with the advisory beside them, the filter uses the original string, nothing is normalised and no request is refused.
2026-11-09 04:17:03 -08:00
billbenetti 7078c754d6 fix(queue): match the unroutable wake retry on the exact prefix (#2824)
The stuck sweep's wake retry (wakeToRetry) and its re-check after the send match "unroutable:" with an exact, case-sensitive startsWith again, as before #2649 moved the sweep's rules from SQL into TypeScript. The repository writes that prefix in lowercase, and findUndelivered and the finding route already match it exactly, so nothing changes on the real data path today; a row whose result matches only when case is ignored is no longer re-sent as an unbound-pane wake. The "failed:" and "qitem-" matches stay case-insensitive, the faithful port of SQLite's LIKE. Follow-up to #2649.
2026-11-09 03:36:58 -08:00
Mike Schwarz 603d0776fb fix(daemon): typing into a writable tile reaches the seat on tmux before 3.5 (#2821)
Just before a writable tile sends its first typed keys, it reads the running tmux server's version (display-message '#{version}'). On 3.5 and later the keys go through the attaching client (send-keys -K, unchanged); below 3.5, or when the version can't be read, they go to the pane and then the tile attaches. tmux 3.3a has no -K and 3.4 doesn't deliver keys sent that way, so typing never reached the seat there; reading the server rather than the client keeps a newer client attached to an older server on the working path. View-only tiles make no extra call. Below 3.5, a tmux prefix typed as the very first input reaches the seat instead of acting as the prefix. Pastes were unaffected. The fix and root-cause trace are w4rc0n's, posted in #2816.
2026-11-09 02:47:51 -08:00
Mike Schwarz 71aba02eb9 fix: deliver owed restore input after bounded unknown readiness (#2818)
An owed restore or audit step waits for two consecutive target_activity_unknown polls at the same stage, then admits only that unknown into the existing finite idle wait; a late positive observation vetoes it. Positive busy and prompt observations still wait or refuse, and explicit draft protection and the same-occupant possible-write /compact hold are unchanged. Each step delivered this way is recorded as delivered_over_unreadable_pane, rig compact --state names it, and the owner diagnostic updates without another wake. The restore owner notice gains the possible-write sentence on its update path and points an unreadable box to the cancel-and-restore route. A test fixture now uses one clock for its idle deadline.
2026-11-09 02:21:27 -08:00
Mike Schwarz ac97493c3a fix: escalate watchdog choice refusals without another reminder (#2822)
A watchdog's positive choice or no-hook refusal now sets the choice age, so owed work blocked by a positively identified prompt enters the existing wake ladder without waiting for another reminder. The age starts at the first still-uncleared choice refusal in the contiguous run, read past the 50-row history window, so a prompt that returns after a clear with no alert still escalates; a current clear vetoes it. Deferred-refusal receipts now carry primaryRow, the obligations, episodeKey and the actual refusal reason. Current-clear, kind-change, manual-closure, shared-alert and permission routing are unchanged.
2026-11-09 02:14:26 -08:00
Dery Ferdika 85ff745a02 docs(test-layers): refresh the Help wanted list at the current command tree (#2778)
Refreshes docs/as-built/test-layers.md's Help wanted list at the current command tree: 87 rows, 7 partial and 5 open. The section note names the tree it was re-checked against; the file-wide last-verified marker stays at its earlier value because only this section was re-verified.
2026-11-09 02:09:02 -08:00
Mike Schwarz 1fb614b7c9 fix(slack): post an eligible ask, marked, when only the person's quiet hours can't be read (#2814)
When only a recipient's quiet-hours setting can't be read, an otherwise eligible Slack notification proceeds to its selected destination, says OpenRig couldn't read the person's quiet hours, carries no mention, and records the uncertainty once per notification key. Only the quiet-hours read is isolated; identity, subscriptions, thresholds and other preferences still decide. This covers ordinary posts, digests and quiet release: asks already held for quiet hours are released at the next check (within about a minute) while quiet hours can't be read, unmentioned and marked. A readable quiet window still holds, escalations keep their mention, and relays don't post on an unreadable read. The supplementary line can be left out at Slack's text or block limits; the row and post-open-asks still report it.
2026-11-09 01:48:28 -08:00
billbenetti c26be7eed8 test(daemon): replace a non-English comment in input-box-reading.test.ts (#2819)
A comment in input-box-reading.test.ts, left partly in Portuguese by an earlier change, now reads: "A blank row among statusLine rows: the idle check rules it out, so the input-box read must too." No assertion changes.
2026-11-09 01:15:26 -08:00
Mike Schwarz 83afa753f5 fix(queue): say when a park on an outside condition has no wake (#2739) (#2817)
An update that parks an item on an outside condition (external:, fold:, auth:) whose waiting view has no timed backstop now returns a park_without_wake advisory naming the owner, the blocker, how to add a wake (--wake-after or --wake-watchdog) and how to unpark (rig queue resolve). It is silent for a park with a live wake, a park on a live item or a person, and any non-park update. The no-timed-backstop text in show is one shared constant. A later --wake-after on the same blocked item already arms its timer on main; a new route test runs the reported sequence through the HTTP route. Reported by @XBS9 in #2739.
2026-11-09 00:13:39 -08:00
Samuel ce459d2e9b feat: launch wrappers for community harness presets (#2759)
launch_wrapper works for community harness presets. A runner-less preset gets the wrapper immediately before its native command; a runner preset keeps its runner on the host and receives the wrapper for its own child spawn, declaring support with launch_wrapper: true. A wrapped seat never starts unwrapped: a runner preset that can't honour a selected wrapper is refused at preflight and launch, naming the preset, the cause and the fix. Built-in runtimes are unchanged. The community-runner increment from discussion #2658.
2026-11-09 00:03:16 -08:00
Mike Schwarz 0830585ede fix(compaction): use draft defaults and report unsent preparation loops (#2799)
Managed compaction no longer holds preparation, /compact or restore on a typed or unreadable input box by default; that protection follows the existing operator choices (a draft-aware typing guard or transport.draft_delivery=refuse). Possible-write reconciliation is kept only for OpenRig's own recorded same-occupant /compact whose submission is uncertain: the first owed turn boundary checks the box with no replay, no speculative Enter and no step recorded as delivered on consumed command text. The owner notice names that hold and how to release it. After three ended automatic preparation attempts that positively sent no input above threshold, one owner finding is raised per episode.
2026-11-09 00:00:45 -08:00
Mike Schwarz 37b22dc646 fix(slack): close a held ask whose person has no connector with no-connector (#2815)
When a quiet-held ask's person has no binding at release, currentAskVerdict records reason=no-connector (the alert list's own binding predicate) instead of below-posting-threshold; the ask still posts nothing and the row stays open on Steering. Bindings that can't be read return unavailable, so nothing is written and the release retries as for an unreadable registry today. post-open-asks and the Slack setup reference name the new reason.
2026-11-08 23:34:10 -08:00
w4rc0n 8c8d797fb4 feat(gateway): support unset on rig gateway human set <id> binding.<n> (#2741) (#2812)
rig gateway human set <entityId> binding.<n> unset removes a single binding slot, keeping the person, their preferences, their other bindings and their open questions, and returns the remaining bindings with their current slot numbers. A primary can't be removed while other bindings remain; the refusal names the current slots, how to replace the primary in place, how to promote another binding (save its full spec from show --json first) and how to remove every binding. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop; an already-open recovery item may remain. No replacement primary is chosen silently.
2026-11-08 23:33:01 -08:00
Mike Schwarz 521405f48d fix(mcp): a scoped connection's root_unknown names only the shared roots it may read (#2811)
rig_artifact_read rewrites a root_unknown error for a scoped (non-owner) MCP grant: it names the root that was asked for and only the daemon's roots the grant allows, or none when that list can't be read. A grant naming a stale root next to an existing one no longer sees other configured root names. The owner, the local CLI and the daemon's own route are unchanged, and every other error passes through. This closes root_unknown's configured-root name enumeration. Other file errors are unchanged; an escaping-symlink path_escape can still include an outside resolved absolute path.
2026-11-08 23:16:42 -08:00
James B. Shields a57285df01 fix(daemon): align slice documents and proof reads with detail (#2808)
Slice document and proof reads use the detail route's project and mission selection (selectSliceDeps) instead of substituting legacy content for an explicit selection. Unqualified reads keep their first-match behavior, and selection errors keep their status and body through the Node adapter. Refs #2487.
2026-11-08 23:13:30 -08:00
billbenetti 7d43d56638 fix(daemon): read Claude's input box past a person's statusLine rows, so a send to that seat can verify (#2798 follow-up) (#2813)
Claude's input-box reader (composerRegion) reads the rows between the last rule and the footer by position, with the statusLine row rule now shared with the idle check (CLAUDE_NOT_STATUS_LINE_ROW). With transport.verify_input_box on, a send to a Claude seat whose statusLine prints rows there reads exact before Enter and empty after it, so it verifies. Blank rows and prompt-, menu- or dialog-shaped rows still read unrecognized. The startup Enter guard reads the same region, so on such a seat it can tell a staged prompt from a cleared box. Other runtimes are unchanged. Follows #2807 for the second effect described in #2798.
2026-11-08 22:54:47 -08:00
Dery Ferdika cc3a31db65 test(daemon): pin how a manifest's export sources resolve (#2777)
Pins how a package manifest's export sources resolve: an absolute, drive-prefixed or UNC-shaped source joins under the package directory, the plan isn't refused, and the missing-file check names the joined path. The existing ../ traversal refusal is unchanged; its message for each export kind now says the source must stay inside the package directory.
2026-11-08 22:52:16 -08:00
Dery Ferdika f3268fa5fe docs(cli-reference): refresh the registered tree counts at b29a70f14 (#2779)
Refreshes docs/as-built/cli-reference.md's registered command tree at b29a70f14: context reconstruction inspect and view options, the ten terminal open follow controls, and an rig mcp serve row.
2026-11-08 22:41:35 -08:00
Mike Schwarz fb02dd9a4a fix(cli): show inherited workspace routes in scope guidance, naming only routes that work (#2810)
Scope guidance shows the workspace routes a command inherits (genius8267's change from #2424, kept under their name). The help example is $(rig config get workspace.projects_root)/<project> rather than a hardcoded ~/.openrig path, and proof's path_escape hint names OPENRIG_URL for another daemon, matching proof's own refusal, rather than rig scope --workspace.
2026-11-08 22:32:17 -08:00
Mike Schwarz 1b83d297ad fix(daemon): name the routes for adopted-session refusals, leading with the one that ends only that seat (#2809)
Adopted-session refusals name the supported routes (genius8267's change from #2423, kept under their name). In a rig with more than one seat, rig seat stop and restart lead with rig unclaim '<session>' and then ending that process in its own terminal, and say that rig down ends all N seats in the rig; a one-seat rig keeps rig down first. The fresh-launch refusal follows the same rule. The rig seat stop help matches. An earlier occupant's handover refusal (#2572) still comes first.
2026-11-08 21:51:14 -08:00
Mike Schwarz d79049c4d0 fix(slack): post a re-shown open ask OpenRig can't fully check, and say what couldn't be checked (#2805)
When rig slack post-open-asks re-shows an open ask and OpenRig can't confirm an earlier attempt (the attempt log can't be read, or Slack can't rule out an earlier post) or can't check that the ask is still open, the ask now goes on through the person's delivery rules and posts once with "Delivery may repeat after an earlier unconfirmed attempt." and/or "OpenRig couldn't check that this ask is still open." It is never recorded as not posted when an attempt may have landed. A readable quiet window holds it for its release, and a definite "no longer open" verdict still withholds it. The quiet release, the away-person deferral and the digest keep the lines when they post it later, and an unreadable attempt log doesn't hold those posts. If the person's delivery policy itself can't be read, a re-shown ask posts and the row and command report it. The command reports what was unconfirmed. The relay path and multi-part limits are unchanged and named.
2026-11-08 21:45:20 -08:00
billbenetti 8b003da576 fix(daemon): prove a Claude seat idle with a person's statusLine rows above the footer (#2798) (#2807)
claudeSeatIdle now finds the input box's closing rule as the first rule above a recognized idle footer and trusts the rows between them by position, so a person's own statusLine row no longer turns an idle seat's reading into unknown. Rows that look like something else (a blank row, a prompt glyph, a numbered choice, a box corner or a framed row) still leave it unknown, and the exact footer, empty box, rules, seat name, notice and a positive completion or fresh status are still required. A seat without a statusLine row reads exactly as before, and the composer reader is unchanged. From billbenetti's report and fix for #2798.
2026-11-08 21:20:22 -08:00
Mike Schwarz b29a70f14f fix(daemon): deliver a deliberate override when the input can't be read (#2800)
An explicit --dangerously-interact send no longer stops on an unreadable input box. The answer is pasted and Enter is sent, with a warning that the box couldn't be read or wasn't recognized, so the staged answer wasn't confirmed. Enter is still withheld when the screen positively shows a recognized prompt, a numbered choice, a box holding something else, or a menu whose key-hint row (an Enter that selects or confirms and an Esc that cancels or closes) is the screen's last row. Fan-out sends print each seat's own advisory on the sent, retained and failed lines. The help, long help and capability map say when Enter is added.
2026-11-08 21:03:50 -08:00
Mike Schwarz a2355b7ffe fix(daemon): say when an unclaimed row's seat can't be read (#2806)
The unclaimed-obligation finding's reason ends "; owner activity unknown (can't read the seat)" when the destination seat's activity reading is missing or unknown, matching the stalled-after-claim finding's wording. A person's row skips the clause, and if the human registry can't be read the clause is left off and the sweep continues. Who is told, the threshold, the dedup and the wakes are unchanged.
2026-11-08 21:01:40 -08:00
Mike Schwarz d13ad48681 fix(daemon): say why Pi account preparation failed (#2803)
When Pi account preparation fails at launch, the runner prints preparePiAccount's own error (for example that an existing Pi login is not a managed account link, or the file system's ENOENT or EACCES) and names the seat's own auth.json and, when one is declared, the account file. It no longer prints the fixed advice to provide a readable prepared account file, and it makes no claim about the login beyond what the error says. The launch still stops.
2026-11-08 20:58:34 -08:00
Mike Schwarz 6b6aa91dc6 fix(daemon): write Codex hook trust records with smol-toml 1.9 (#2804)
smol-toml 1.9 builds TOML tables as null-prototype objects, so the exact comparison after writing a Codex hook trust record failed and the record was skipped as not written exactly. The write and move checks now compare the parsed tables as plain objects before the same isDeepStrictEqual. Comparisons between two parser results, an owner's own settings, explicit disabled choices and smol-toml 1.8 behave as before. A remaining real mismatch still warns and leaves the files untouched. Thanks to djuntgen for the report, the versions, the log line and the reproduction.
2026-11-08 20:39:46 -08:00
Mike Schwarz 8064fc89f6 fix(daemon): a Claude seat's earlier occupant can't replace its resume target (#2796)
When a Claude seat's session_identity hook comes from an occupant generation that a later launch replaced, it no longer replaces the resume target of the occupant now running. A seat adopted without a relaunch (reconcile, bind, self-attach or claim) continues its live occupant's tenure, so that occupant's hooks keep updating its target; any later launch, fresh start or handover still makes it an earlier occupant. Codex seats get the same continuity through the same guard. This reads the existing occupant ledger: no schema, registration or new refusal. Limits: an all-adoption ledger shows continuity of the record, not that the same process is running, so bind or self-attach of a different existing process is treated as continuation; and a process started by hand in the seat's tmux session after a handover inherits the earlier generation, so its hooks are refused. Thanks to Kayhusk for the report and the traced guard span.
2026-11-08 20:34:21 -08:00
Mike Schwarz 7f4e326f27 docs: carry the 0.7.11 capability map teaching onto main (#2802)
Carries the 0.7.11 capability-map teaching from the release source onto main: the capability-delta-v0.7.11 marker, the 92-verb count, draft delivery by default, rig acp serve --to, tiles that show without attaching, queue verbs over --host and queue artifacts, workflow trace --full and objectives, launch_wrapper, the openrig plugin and --front-door, the Linux tmux user scope, the OpenShell 0.7.11 result and its limits, and a grant service's task destination and question tools. A three-way merge from the release base keeps main's newer Pi account-home --reason text.
2026-11-08 19:56:24 -08:00
Mike Schwarz 46ba2f5d6b docs(skills): scope fail-closed to artifact integrity; ask what happens when a change can't tell (#2801)
Scopes agent-operated-software's "consumers fail closed" to artifact integrity (don't read a file before its sentinel exists), and adds that for delivery, routing and checks, uncertainty fails open: deliver and report what couldn't be checked. When a change can't tell whether it would expose a credential, delete someone's data, spend money or publish, it stops and asks. review-team (both copies) gains one review question: what does this do when it can't tell, and if it stops or refuses, is there positive evidence or an operator opt-in? The skill-edge digests are regenerated.
2026-11-08 19:34:49 -08:00
Mike Schwarz 3de06efe4c fix(compaction): retain input-box reading on held preparation (#2797)
A preparation held by the input-box check now records the reading on its attempt: status, confidence, the fixed evidence strings and the cursor, with no draft or suggestion text. It appears in getPreparationState (and so rig compact --state), the attempt state file and a recovered preparation. The transport passes the selected metadata through its existing preserveInput hold; the existing status strings are unchanged. Captures, refusals, retries, waits, Enter and compaction decisions are unchanged.
2026-11-08 18:52:12 -08:00
Seán McNamara c415da618d fix(slack): recover threads by need, not oldest first (#2788)
Thread recovery scans each tracked thread once after live events last began (boot, or a reconnect after a gap), with coverage counted only once it reaches the connect time. The first scans go to threads leaving the seven-day window soon, then asks waiting on their person, then the rest, newest first, derived from saved coverage so a restart keeps the order. A thread whose call made no progress (an error, an untrusted page, an unfinished landing) waits behind every other unscanned thread, least recently stalled first; a short or partial scan keeps its place. Afterwards a thread is rescanned only as a backstop: hourly while its ask waits, daily otherwise, every three hours for a settled thread on a hub-relayed machine. thread_not_found is saved and re-asked at most daily. rig slack status shows a settled count with its rescan interval.
2026-11-08 18:41:00 -08:00
Seán McNamara a8574f8094 fix(slack): ask settlement remembers a deleted original across restarts (#2792)
Settlement records a gone original (message_not_found, thread_not_found, or a read that finds no message) on the ask's row as slack-ask-settle-unavailable with the edit's notification key, channel and message ts, and skips that exact message from then on; a later post of the same ask is still edited. cant_update_message, a missing scope, membership, channel or archive state and invalid blocks stay in the process-lifetime skip and retry on the next start. desired() ignores the new note. If the note write fails, the in-memory skip still applies. The Slack setup guide describes the split.
2026-11-08 18:39:26 -08:00
Mike Schwarz 30b8232181 fix(daemon): a paste during a tile's attach stays one paste (#2795)
The writable tile's attach script no longer writes \033[?2004l after the first key and before tmux attaches. tmux 3.4 and later turn bracketed paste back on only when the client starts, so a paste in that gap was sent unframed and each line reached the agent as Enter. The no-attach return still restores the terminal, and detach is unchanged. The tile-size helper's paste-framing sample is back to its original place after the key, and a generated-script cell asserts exactly one framing-off write, on the no-attach return.
2026-11-08 18:26:22 -08:00
Mike Schwarz ae04d252ba fix(transport): recognize update notices beside the paste hint (#2786)
After #2662, a draft-aware send admitted an empty composer beneath Claude's installed-update banner, pasted, and then re-read the composer through the shared footer grammar; a paste-hint row combined with the update notice fell outside that grammar, so the reader returned unknown and Enter was withheld with the text left in the composer. The shared PASTE_HINT pattern now also accepts the complete existing installed-update spellings after two or more spaces. Unknown suffixes still hold, so an unrecognized future rendering still refuses safely after the paste. Startup submission paths that share the parser also recognize the row, under their existing guards. No new wait, capture or I/O.
2026-11-08 15:59:37 -08:00
Mike Schwarz 4147d7d0d8 test(daemon): say what the seat got when the tile's key-then-paste wait fails (#2787)
The tmux tile-size test helper's wait_for takes an optional detail, evaluated only when the wait times out, and the key-then-framed-paste wait passes one with the framed value it sampled, the seat's last 80 received bytes and the expected tail. The assertion and timing are unchanged. Test-only.
2026-11-08 15:55:56 -08:00
Mike Schwarz 2a17eef1de fix(daemon): a Pi seat with an account home starts again (#2782)
A Pi seat whose spec declares an account home composed its launch, resume and account-switch commands as 'unset VARS; /usr/bin/env ...', and the tmux staging script runs every command as 'exec <command>', so /bin/sh tried to exec 'unset' and the seat never started (exec: unset: not found). The account step now passes the same variables to /usr/bin/env as -u options, making the command a single simple command that exec replaces the staging shell with, so the agent keeps the staging shell's process. The scrub list, the selected account home, clear semantics and the failure message are unchanged; Pi commands without an account, and Claude, Codex and community-harness commands, compose exactly as before.
2026-11-08 15:36:31 -08:00
James B. Shields 1c9d0076cf fix(daemon): keep source scope on new recovery findings (#2776)
When the queue's stuck sweep creates a recovery finding, it now copies the recovered row's project:, mission: and slice: tags from the freshly read source row onto the finding, using the same scope-tag pattern answer rows already use. No other tags are copied, and the finding's own sweep, dedup, recovery-for and escalation-hop tags, its dedup identity and the standing notice for a seat's unclaimed rows are unchanged.
2026-11-08 13:55:03 -08:00
Mike Schwarz 2add9baaf5 docs: correct help and reference text found in the 0.7.11 docs read (#2780)
Corrects six help and reference passages found in the 0.7.11 docs read. The capability map's Pi account-home move and clear show the required --reason. The OpenShell reference README says saved startup actions are not resent but a restored guest's first input asks it to read the projected CLAUDE.md and its startup files again, so strict recall after a restore is not met. seat-delivery.md says that under the default transport.draft_delivery (deliver) an ordinary send that meets a person's draft is joined to it and submitted unless the seat's typing guard holds input, and that refuse refuses the send. cli-reference.md marks rig gateway human add --binding as optional. hosted-members.md documents rig mcp serve --task-destination <member@rig>. The rig-spec launch-wrapper heading names pi, codex and claude-code and keeps its previous anchor for existing links. Docs only.
2026-11-08 13:43:28 -08:00
Mike Schwarz 0964fb7bd4 docs: add the required --delivery-class to the hosted-members register example (#2775)
The register example in docs/reference/hosted-members.md left out --delivery-class, which rig gateway human add requires, so copying it failed with a usage error. It now reads rig gateway human add <entity_id> --display-name "<name>" --delivery-class B --default, matching the gateway-participants examples; class B needs no connector binding. Docs only.
2026-11-08 12:40:53 -08:00
Mike Schwarz e1dedbf531 docs(cli): name the restore fields and refusal exits in rig up --help (#2773)
The rig up help paragraph about exits after a restore now names each node's status, noConversation, guidanceGaps and restoreGaps, says a seat with a gap keeps its own status and that a fresh seat marked noConversation (an ordinary terminal seat) lost nothing, and adds one sentence for refused requests: exit 1 for a conflict (HTTP 409, such as a rig still running or a restore not attempted) and 2 for any other refusal (400, 404 or 500). Help text and its test cell only; behaviour, exit codes and --json output are unchanged.
2026-11-08 12:15:45 -08:00
Mike Schwarz faff36aa93 fix(daemon): quiet expected restore warning after clean stop (#2766)
Teardown records a session's intentional exit and then deletes its tmux binding, so a restore after a normal clean stop always reached FR-5's missing-binding fallback and warned "no durably-bound session name" although nothing was lost. Restore now reads the node's newest session (created_at DESC, id DESC, the same newest-session rule the rehydrate and teardown paths already use) and stays quiet only when there is no binding row and that session is exited with its intentional-stop marker set. A newer unclean, unknown or superseded session, legacy rows without the marker, an existing binding with an empty name and an invalid pin still warn; an older intentional stop cannot quiet a newer session. Full restore and subset launch share the block. Session-name derivation, resume name, type and token, and compensation are unchanged. Cost: one indexed newest-session read on the missing-pin path.
2026-11-08 11:21:03 -08:00
Mike Schwarz e806c4d3db fix(cli): say why rig up exits 1 when every restored seat is running (#2767)
After a restore in which every seat is running but some started fresh without their conversation, or came back with a guidance or restore gap, the daemon reports partially_restored and rig up exits 1, with no line saying why. rig up now prints one summary line in that case, naming how many seats started fresh and which seats have a gap, and rig up --help explains what exit 0 and exit 1 mean after a restore and where to look in the --json output. The line uses the same conditions as the daemon's restore rollup. The exit code, the --json output and the daemon are unchanged; launchStatusRunning is exported for reuse with its set unchanged.
2026-11-08 11:19:16 -08:00
Seán McNamara 5eaf0c1a2e feat(daemon): support confirmed Pi cross-runtime switches (#2669)
rig seat set-runtime now supports exactly four Pi directions (Pi to and from Claude Code, Pi to and from Codex) through the existing confirmed-successor switch: a fresh native conversation and a handover, explicit target settings and credentials, recoverable claim custody and the existing wrapper limits. Switching into Pi with an explicit --clear-account-home restores the seat's original Pi login when it had one. A Pi seat with a launch_wrapper switching to Claude Code is refused before staging, since that combination hasn't been qualified; clearing the wrapper and re-applying the spec is the way through. Contributed by seanmcn.
2026-11-08 10:18:03 -08:00
Mike Schwarz 0a91a8f0f1 fix(daemon): let preparation hooks age before managed compact (#2764)
The managed compaction's guarded final send waited 1 s plus the 8 s readable budget, while a fresh activity hook from its own preparation keeps the seat reading as running for 15 s, so an exhausted seat whose pane was idle could time out before /compact was ever sent. The default idle wait now uses the transport's existing readiness freshness window (SEND_READINESS_FRESHNESS_MS), still clipped to the original attempt deadline, and the guarded replacement preparation send uses the same default. A genuinely running seat, a permission prompt or typed input still holds the send, and cancellation, generation and unknown-write handling are unchanged. Cost: a busy seat's guarded send can wait up to about 14 s longer.
2026-11-08 10:10:43 -08:00
billbenetti 1760d6e776 fix(daemon): make rig workflow validate read-only (#2726) (#2763)
rig workflow validate parses and validates a spec in memory (the same parseWorkflowSpec and WorkflowValidator checks as before) and no longer writes to workflow_specs, so a new id@version stays unregistered and a draft with a registered id@version from another path no longer replaces it (#2726). Registration stays with instantiate, run, the workflows-folder scan and unregister; a draft validated only outside the workflows folder is reachable by its path, not its bare name. No new error code. Contributed by billbenetti.
2026-11-08 09:58:48 -08:00
538 changed files with 37168 additions and 2392 deletions

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v4
with:
+8 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/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.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:
This runs OpenRig's install script from the `v0.7.11` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
@@ -519,6 +519,9 @@ OpenRig is open source and self-hosted, with Claude Code and Codex in the same t
- **Website**: [openrig.dev](https://openrig.dev)
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs) ([documentation index for agents](https://openrig.dev/llms.txt))
- **What's new**: [openrig.dev/whats-new](https://openrig.dev/whats-new) (every release, in plain words)
- **Features**: [openrig.dev/features](https://openrig.dev/features)
- **Integrations**: [openrig.dev/integrations](https://openrig.dev/integrations) (assistants, harnesses and environments, with setup and test status)
- **Blog**: [openrig.dev/blog](https://openrig.dev/blog) · [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
- **Videos**: [youtube.com/@openrig](https://www.youtube.com/@openrig)
+2 -2
View File
@@ -6,9 +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.10
## Released in 0.7.11
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.11 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.11): OpenRig 0.7.11 is a patch for people running their own agent teams. An ordinary send or work notice now goes into a person's typed draft by default instead of being refused (see Behaviour that changed). See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
@@ -398,6 +398,21 @@ 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`).
`HerdrAppProvider` (`herdr-app-provider.ts`) opens a view in a herdr-app
window (one provider per app home, cached in `startup.ts`). In the daemon it is
used only when named (`resolveHerdrAppProvider` in `terminal-service.ts`). Its
transport (`herdr-app-transport.ts`) sends one JSON line per
connection to `<app home>/herdr-app.sock` and reads the window's own "did not
answer" as `unknown`. A view is a child of OpenRig's stack in a derived
`layouts/rig.json` (`deriveRigLayout`, `herdr-app-layout.ts`), written
atomically, with viewers started by `hc run --bg --no-parent` and journalled
first (`herdr-app-state.ts`). Removal rewrites the file before a viewer ends,
and ends it only after the `rig-strip` mod's acknowledgement
(`<app home>/openrig/ack.json`) shows a layout without it. An `hc events`
watcher (`startup.ts`) turns a viewer that ended elsewhere into an ended cell.
Its four mods ship in `packages/daemon/assets/herdr-app/mods/` and are
installed by `herdr-app-install.ts`.
### Tmux server selection
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
@@ -237,7 +237,9 @@ The sweep reports six finding kinds. Its custody check covers closures that
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
24 hours, plus older closures with open findings. A seat target is satisfied by
a local successor carrying this row's lineage, and any target by a
`custody-verified: <target> …` note on the closed row. A new custody finding for
`custody-verified:` note on the closed row: per note, its first word that names one
of the row's targets exactly (or a comma list of them) is what it verifies, and later
mentions don't count; the finding prints each target's note. A new custody finding for
a human destination goes to the agent that closed the item, using the closing
transition from active or archived history. Later notes and notification
receipts do not change that owner. If the closing actor is missing, human, system
@@ -278,7 +280,13 @@ canceled, no-follow-on, escalation, superseded}`. The reasons
`closure_target` (`:86`):
- `handed_off_to` — work continues with a different seat (`closure_target` =
new owner).
new owner). `rig queue show` on such a row names the row the handoff made in
`successors` (rows whose `handedOffFrom` is this row, and a local row its
closure target names), or says in `successorNote` why none is named. A
cross-host successor is named as recorded (`<id>@<host>`); nothing stored
changes. Past 20 such rows the newest 20 are named, with
`successorsTruncated: true`. A scoped MCP caller doesn't get these fields;
`rig_queue_show`'s scope-checked `successor` answers it instead.
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
To wait for a qitem, use `rig queue block <id> --on <child>` or
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
@@ -160,8 +160,9 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
`:780`; if `status === "restoring"` the node is skipped with a warning and
reported as `fresh`, `:783–785`; `degraded` only adds a warning).
- Reuses the seat's durably bound tmux session name when the prior binding
has a usable one (`:840`), and otherwise falls back to the derived name
with a warning (`:869`).
has a usable one, and otherwise falls back to the derived name. A missing
binding after the latest session's recorded intentional exit is quiet;
unclean/legacy missing bindings and incomplete or invalid binding rows still warn.
- Replays restore-safe startup using persisted startup context;
prefilters missing optional artifacts into warnings; **hard-fails a node if
a required startup file is missing** (`:1161` — status `"failed"`, error
@@ -169,9 +169,19 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
posture, one for each mode OpenRig can launch: bypass
(`⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents`), accept
edits (OpenRig's default), auto, don't ask, plan, and default
(`⏸ manual mode on · ← for agents`); no statusLine row between the
closing rule and the footer (OpenRig's statusLine collector prints
nothing); a blank, effort or tmux-notice row above the box; and above
(`⏸ manual mode on · ← for agents`); between the closing rule and the
footer, either no row (OpenRig's statusLine collector prints nothing) or
the rows a person's own statusLine prints, with the indicator Claude
draws at the right of the first one (`● high · /effort`), trusted only by
position, as the composer reader trusts them (#2703, #2798): the closing
rule is the first rule above the footer, the frame above it must check
out, and no row between them may be blank or look like a menu or dialog
(`❯`, `›` or `»` anywhere in the row, a row that starts with a numbered
choice such as `1. ` or `2) `, with or without a border or gutter before
it, a box corner, or a row framed by side borders); so a statusLine that
uses `›`, `»` or `❯` as a separator, prints a blank row or starts a row
with a number and a dot reads `unknown`; a blank, effort or tmux-notice
row above the box; and above
that, in the status slot, the completion row of the seat's last turn
(`✻ <verb> for <duration> · done <time>` at column 0, matched by the
Claude Code 2.1.296 bundle's grammar: one of its eight past-tense verbs,
@@ -204,13 +214,15 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
status row, `esc to interrupt`, `paste again to expand`, a queued label,
Codex's pending-input headers or `↳` rows, a spinner). Any other frame,
including every running, streaming, steer-preview, paste-hint or unknown
layout, another seat's name, a statusLine that prints a row, a background
shell in the footer and a fullscreen renderer with a statusLine (which
reserves a row for it), reads `seat: not-idle` or
`unknown`, and the send reads unconfirmed. This holds when a running turn
hides its signal (Codex hides its `• Working` row while it streams;
Claude Code shows only `paste again to expand` in its footer for 8 s
after a paste, and no `esc to interrupt` under a statusLine). So a send to
layout, another seat's name, a second rule drawn under the box, a blank,
prompt-, menu- or dialog-shaped row between the closing rule and the
footer (so a fullscreen renderer that reserves a blank row for the
statusLine reads as before), and a background shell in the footer, reads
`seat: not-idle` or `unknown`, and the send reads unconfirmed. This
holds when a running turn hides its signal (Codex hides its `• Working`
row while it streams; Claude Code shows only `paste again to expand` in
its footer for 8 s after a paste, and no `esc to interrupt` under a
statusLine). So a send to
a busy or unknown seat always reads unconfirmed.
**Versions.** The layouts were recorded from Claude Code 2.1.296 and
@@ -272,7 +284,13 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
characters never widen it. Any other break, and a break inside a word of
the text, reads `ambiguous` and the send stays unconfirmed. A collapsed paste label, Claude's
queued-messages label or `Try "…"` suggestion, or another draft is never
the message. The default `--verify` matcher above is unchanged.
the message. The default `--verify` matcher above is unchanged. Claude
Code's box ends at the last rule on the screen; the rows under it up to
the footer are a person's statusLine, read by position with the same
rule as the seat check above (#2798), so a box with statusLine rows
under it reads as one without them, and a blank, prompt-, menu- or
dialog-shaped row there leaves it `unrecognized`. The startup Enter
guard reads the same region.
- **The box is empty after Enter on the same pane process.** The capture
after Enter must show the box empty, with the pane process
(`#{pane_pid}`) and the seat's binding unchanged.
@@ -84,6 +84,15 @@ Diagnostic-only rows, moved sources, and versions retained for unfinished
work have separate paths in `readThrough`, `writeDiagnostic`, and
`isPinnedByUnfinishedWork`.
Only instantiate (and `rig workflow run`, which posts to the same route) and
the workflows-folder scan register specs through `readThrough`.
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
checks, in memory, with no `workflow_specs` write. A spec that was only
validated from outside the workflows folder is not listed by
`rig workflow specs` and is not found by its bare name; its path still
instantiates (#2726).
## Durable state
The migration files record the storage evolution; they are not a count of
@@ -387,14 +387,28 @@ that selects by mission. A slice's `qitemIds` come first from rows tagged
project-scoped indexer only rows tagged `project:<id>` count, so untagged
historical rows are not attributed to a selected project.
> Source: `domain/slices/slice-indexer.ts:2–15` (contract + no new state),
> `:238` (TTL), `:297–299` (`isReady`), `:331–336` (`invalidate`),
> `:457–472` (`reconcileComposition`), `:476–521` (`readSliceLocations`;
> first name wins `:478–483`; nested-vs-flat `:495–517`), `:61–88`
> (`SliceRecord`: `missionId` `:65`, `slicePath` `:67`, `qitemIds` `:77`,
> `proofPacket` `:81`), `:90–111` (`SliceListEntry`, which carries
> `qitemCount` / `hasProofPacket` instead), `:865–875` and `:936–946` (queue
> membership; project filter `:870`, `:942`); `domain/workspace/project-catalog.ts:132–138`
The `GET /api/slices` response keeps that legacy `qitemCount` separate from
`queueCounts`, eight current-state counts (or `null` when the listed
mission/project scope cannot be resolved or catalog/candidate-root discovery is
incomplete). `queueCounts` matches exact typed tags in one uncapped queue scan
and is recomputed on every request; `?refresh` invalidates the separate listing
cache, not a queue-count snapshot. A row may omit `project:` or `mission:` only
when its remaining scope tokens identify exactly one physical slice with a
resolved project and mission across the configured indexer roots and the
catalog projects' missions roots. If catalog/project resolution or a candidate
root cannot be read completely, counts stay `null` rather than treating unseen
candidates as absent. Ambiguous matches count nowhere, including matches hidden
by first-wins listing or a list filter. An explicit mismatched project or
mission never counts. Physical candidates are used for counting only and do
not change which slice is listed or served.
> Source: `domain/slices/slice-indexer.ts` (read-only/no-new-state module
> contract; `SliceRecord.qitemIds` / `SliceRecord.proofPacket`;
> `DEFAULT_CACHE_TTL_MS`; `isReady()`; `invalidate()`;
> `reconcileComposition()`; `readSliceLocations()`;
> `observedQueueCounts()` physical-candidate matching and the shared queue
> scan; `toListEntry()` / `matchQitems()` legacy membership);
> `domain/workspace/project-catalog.ts:132–138`
> (`belongsToProject`); startup `startup.ts:1705`, `:1718` (`OPENRIG_SLICES_ROOT`
> first), `:1725–1730`, `:1743–1748`, proof-source watch `:1762–1765`
> (`domain/proof/source-watch.ts:16`); default
@@ -433,7 +447,16 @@ handlers (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/slices.ts`)
`?boundToWorkflow=<name>:<version>` lens narrows to slices bound to a
workflow instance (400 `boundToWorkflow_invalid` without the colon). Each
slice row carries `readiness`, and a `missions` sidecar carries authored
mission status and readiness. Rows sort by last activity, newest first.
mission status and readiness. The list row also keeps legacy `qitemCount`
separate from `queueCounts`: the latter has one count for each supported
queue state, or `null` when the row's project/mission scope is unresolved.
It counts rows whose exact typed tags match one physical slice with a
resolved project and mission across all configured roots. A missing
`project:` or `mission:` is accepted only when the remaining tags select
exactly one such candidate; ambiguous candidates count nowhere, even if
first-wins discovery or the status filter exposes only one
listed row. Explicit mismatched qualifiers never count. Counting does not
change list or filter behavior. Rows sort by last activity, newest first.
- `POST /refresh` — drops the indexer caches, no daemon restart.
- `GET /:name/proof-asset/*` — serves a proof asset from the slice's matched
dogfood-evidence directory; rejects `..` (400); `Cache-Control: public,
+93 -14
View File
@@ -9,23 +9,23 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
last-updated: 2026-10-26
last-verified-against-source: b29a70f14fc11797d46cbd9f12d4e61a523c3d06
last-updated: 2026-11-09
---
# CLI Reference — Registered Commands and Options
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
There are **90 top-level registrations**, **410 registered command objects**
There are **92 top-level registrations**, **440 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
**1,460 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. 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.
These are counts for that source snapshot, not a claim about a deployed release. Every registered family is inside the snapshot: the `exec` and `environment` families, the `roster` and `telemetry` rows, and the `host edit`, `host remove` and `terminal attach` rows that earlier snapshots listed as later additions are all counted here, as are the herdr-app provider's `terminal close` row and its `--app-home` options.
## Reading this reference
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` (repeatable; without one, the person is reached in the Feed and Steering only)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
@@ -226,6 +226,7 @@ the sections that were not read are `null`.
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
- `human set <entityId> binding.<n> <spec|unset>` — replaces `binding.<n>` with a full `kind:connectorRef:secretsRef:role[:handle=<id>]` spec, or removes that slot when `<value>` is `unset`. Removing a binding keeps the person, their preferences, their other bindings and their open questions, and prints `remainingBindings` (with their shifted `binding.<n>` slot indexes; the primary's slot shifts down too when a lower-index secondary is removed) plus `note`. No replacement `primary` is chosen silently: unsetting a `primary` while secondary bindings remain is refused, teaching how to replace the primary slot in place, promote an existing secondary binding (first save that binding's full spec from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` or `rig gateway human show <entityId> --json` and set it to the saved spec with role `primary` — promotion replaces the former primary binding, and its connector reference and inbound handle are not retained as a secondary), or unset the secondary slots first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>` and finish at `binding.0` when removing every binding. Removing the last binding makes the current delivery episode inactive so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain, and the current delivery-outcome display may disappear while historical receipt notes remain); it does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.
### parked
@@ -353,6 +354,8 @@ the sections that were not read are `null`.
| `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 approve <request>` | — | `--bearer <token>`<br>`--json` |
| `rig host deny <request>` | — | `--bearer <token>`<br>`--json` |
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
@@ -361,12 +364,58 @@ even when it is bound to a tailnet address and so starts without one: pairing ha
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
approval says how long and until when, in UTC. A target that reports who was asked (`person`) and who reads their answer (`owner`), or why
nobody does yet (`noReader`), has `rig host pair` name them; an older target's reply keeps the general text. With
nobody registered on the target, its refusal (409 `pair_human_required`) says to register a person there:
`rig gateway human add <entityId> --display-name <name> --delivery-class B --default`. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
Steering, or in Slack: with Approve or Deny, or in their own words. Typed words never deny it.
- **When the target has a receiving agent** (the one chosen in `agents.operator_session`, else its kernel operator
`operator-agent@kernel`), it asks. The person's answer, a click or their words, comes back to it as a reply, and it
decides with `rig host approve <code>` or `rig host deny <code>`. It asks the person back when the answer isn't clear.
The person is told once that their answer was received. If the agent isn't running, the request and that receipt say
so, and they name the direct controls (below). The request stays pending, never granted or denied, until a decision or
expiry. If the agent doesn't pick up the answer, the person is told at expiry that it didn't.
- **When the target has no receiving agent** (or the queue can't send rows to it, or OpenRig couldn't read which; the
request says which, and names a chosen agent it couldn't use), the person's answer, a click or their words, is kept on
the request and decides nothing. (A Steering Deny or Drop, which closes the request, still refuses it.) The person
gets one receipt naming the direct controls. If a receiving agent is set
before the request expires, it gets their answer once and decides. (On 0.7.11, Approve and Deny decided at once here,
and words were asked again.)
- **The direct controls:** the target's TUI pairing settings (`rig tui`, then Settings, Instance & work, Pairing
requests; the Feed's request opens it there), or `rig host approve|deny` below. The TUI lists the open requests from
`GET /api/hosts/pair-requests` (code, requester, expiry, status, who reads the answer, and the person's kept answer,
so an Approve over a kept Deny is made knowingly; no credential), reads the
request again before Approve or Deny, and decides through `POST /api/hosts/pair-decision` with its operator bearer
token. A decision it can't confirm (a timeout, a lost connection or a server error) reads "outcome unknown" and is
read again, never sent twice.
- **Either way,** the outcome reaches the person as its own update: approved or denied, and by whom.
On either target, `rig host approve <request>` or `rig host deny <request>` (the pairing code, or the request's
approval row id), run on the target from an agent seat or a plain terminal, decides it. It needs the target daemon's operator bearer
token: an agent seat carries `OPENRIG_AUTH_BEARER_TOKEN`, and a person sets it or passes `--bearer <token>`. Without
it the daemon answers 401, and nothing is decided. It decides once, and refuses an
unknown (404 `pair_request_unknown`), expired (409 `pair_request_expired`) or already-decided (409
`pair_request_decided`) request, including one its row already decided before the requester's next check (an agent
seat's close naming Approve, or a refusing close). A close never approves over the person's recorded Deny. It records who decided: the seat, or `human@host` from a plain terminal. The person
gets one update with the result. An agent seat's `rig queue update <id> --state done --closure-reason no-follow-on
--resolution approve` on an unanswered request still approves, and any other close that carries no answer and isn't
the person's own reply, or `--state denied`, refuses. To refuse a pairing, use Deny on the target's pairing settings or on Steering, run
`rig host deny`, or let it expire (nothing is persisted). At its deadline a request's ask closes as expired, even when the requester stopped waiting, so a late click there isn't
accepted. The decisions live in the daemon's memory, as the requests do, so a daemon restart drops an open request.
A 0.7.10 or earlier target asks no question. Its person approves with the request's Approve, in the TUI (`rig tui`,
then `attention`: open it; the TUI from 0.7.1) or on Steering, and from an agent seat `rig queue update <id> --state done
--closure-reason no-follow-on` approves. There, every close that leaves the request `done` approves it, whatever its
closure reason: a typed reply, or an agent seat's `rig queue update <id> --state done --closure-reason denied`,
included. To refuse a pairing on such a target, let it expire, or from an agent seat set the request `denied`
(`rig queue update <id> --state denied`); a `canceled` or `failed` state refuses too.
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
@@ -406,7 +455,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>`<br>`--front-door`<br>`--task-destination <seat>` |
| `rig mcp token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
@@ -491,7 +540,7 @@ default-permit record (tag `runner-permission-permit`) opens no row.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--source <name@external>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
@@ -510,6 +559,7 @@ default-permit record (tag `runner-permission-permit`) opens no row.
| `rig queue inbox-pending <destinationSession>` | — | `--json` |
| `rig queue outbox-record` | — | `--sender <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <outboxId>`<br>`--json` |
| `rig queue outbox-list <senderSession>` | — | `--limit <n>`<br>`--json` |
| `rig queue erase` | — | `--members <file>`<br>`--rig <id>`<br>`--ids <file>`<br>`--action <action>` (`delete`, `scrub`)<br>`--routine`<br>`--apply`<br>`--actor <session>`<br>`--json` |
### slack
@@ -569,13 +619,16 @@ default-permit record (tag `runner-permission-permit`) opens no row.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig terminal` | — | — |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--app-home <dir>`<br>`--expected-plan <id>`<br>`--follow`<br>`--no-follow`<br>`--max-tiles <count>`<br>`--pin <seats...>`<br>`--unpin <seats...>`<br>`--suppress <seats...>`<br>`--unsuppress <seats...>`<br>`--suppression-ms <ms>`<br>`--grace-ms <ms>`<br>`--focus-retention-ms <ms>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
| `rig terminal close <view>` | — | `--app-home <dir>`<br>`--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--app-home <dir>`<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.
herdr-app views (`--provider herdr-app`, plain `rig terminal open <view>` inside an answering herdr-app terminal versus `--window` for a desktop window, `close`, `--app-home`) are in
[herdr-app](../reference/herdr-app.md).
### watchdog
@@ -674,6 +727,9 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig context` | — | — |
| `rig context reconstruction` | — | — |
| `rig context reconstruction inspect` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
| `rig context reconstruction view` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
| `rig context work-install` | — | `--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--deliver`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--topology <ids>`<br>`--apply-skills`<br>`--json` |
| `rig context trace` | — | `--rig <rig>` **required**<br>`--pod <pod>`<br>`--seat <seat>`<br>`--name <file>` **required**<br>`--json` |
| `rig context compose` | — | `--out <ref>` **required**<br>`--from <files...>` **required** |
@@ -682,7 +738,7 @@ Only Claude and Codex tenures can be woken.
| `rig context preview <name-or-ref>` | — | `--json` |
| `rig context get <name-or-ref>` | — | `--json` |
| `rig context profile <name-or-ref>` | — | `--situation <situation>` **required**<br>`--runtime <runtime>`<br>`--profile <profile>`<br>`--budget <tokens>`<br>`--rig <rig>`<br>`--seat <seat>`<br>`--mission <mission>`<br>`--slice <slice>`<br>`--json` |
| `rig context recap-write` | — | `--rig <rig>` **required**<br>`--seat <seat>` **required**<br>`--file <path>` **required** |
| `rig context recap-write` | — | `--rig <rig>` (default: the calling seat's rig)<br>`--seat <seat>` (default: the calling seat; the part of its session name before `@`)<br>`--file <path>` **required** |
| `rig context sync` | — | `--json` |
| `rig context add <source>` | — | `--name <name>`<br>`--git`<br>`--checkout`<br>`--pack <path>`<br>`--json` |
| `rig context source` | — | — |
@@ -1036,6 +1092,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| `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 decide-permission <request>` | — | `--option <id>` **required**<br>`--reason <text>`<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>`--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` |
@@ -1082,6 +1139,28 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
|---|---|---|
| `rig handover <seat>` | — | `--source <source>`<br>`--reason <reason>`<br>`--operator <address>`<br>`--dry-run`<br>`--json` |
A rebuild starts a new native conversation and primes it from durable artifacts. In the JSON result,
`currentStatus.continuityOutcome` describes the executed source (`rebuilt`, `forked`, `fresh`, or unknown
for a discovered successor); `sourceOutcome` lists the rebuild artifacts and gaps or the fork origin.
The legacy `continuityTransferred` field is always `false`, not a transfer verdict. A complete binding
does not prove a lineage write: `sideEffects.provenanceRecordWritten` reads back this handover's occupant
tenure. If it is false, inspect the daemon log; do not repeat a handover whose seat is already bound.
For Codex rebuilds, `sourceOutcome.permissions` distinguishes an emitted predecessor policy, a saved
successor selection, and an unverified fallback. A saved `--codex-permissions` envelope wins over the
observed predecessor policy and retains the usual launch additions: workspace-write adds Git metadata,
seat-folder and pod-state writable roots; read-only adds none. The result describes this launch policy,
not an observed exact difference. A supported observed predecessor policy is carried without fresh
directory additions when no newer saved selection replaces it.
An unreadable or unsupported predecessor policy does not stop the rebuild; without a saved envelope,
the ordinary launch settings apply and the result reports the missing comparison. Emitted arguments
are not proof of the successor's native enforcement, and the named Codex config profile still loads.
`sourceOutcome.model.requested` is the seat's configured Codex model pin, or `null` for the runtime's
configuration/default. A rebuild reapplies that choice rather than the predecessor's in-pane model;
`predecessorCompared: false` explicitly reports that no native model comparison was performed. This
reports launch intent, not the model actually used by the successor. Older daemons omit this object.
### startup-proof
| Invocation | Aliases | Declared options |
+9 -4
View File
@@ -8,7 +8,7 @@ applies-when: |
read it before claiming a stub-agent scenario covers a behaviour.
siblings: [arteries.md, README.md, codemap.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-updated: 2026-11-09
---
# Test layers: what to run before you push, and what each layer proves
@@ -448,9 +448,12 @@ not as current status.
## Help wanted: command families without a behavioural scenario
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
> top-level families. At `e8f0ab34`, `packages/cli/src/index.ts` registers 87 top-level
> commands; `roster` and `telemetry` were added since and have no row yet. Before picking one up, check `packages/test-system/scenarios/`, the cases in
> This list may be stale. The tables below cover the families that have a proposed first
> check: at `b29a70f14`, the Commander tree returned by `createProgram()`
> (`packages/cli/src/index.ts`) registers 92 top-level families, 87 of which have a row
> below (7 of them marked partial); `roster` and `telemetry` now have rows too. These 5
> families still have no row: `acp`, `community-harness`, `edge`, `environment`, `exec`.
> Before picking one up, check `packages/test-system/scenarios/`, the cases in
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
> check only proves the command is discoverable. Existing unit tests are not counted here.
@@ -522,6 +525,8 @@ Before you start, two honest constraints:
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
| reads | `roster` | A scratch workspace with two rigs and a rosters folder: `rig roster find` returns the expected specialists, and an empty folder reports none without an error. |
| reads | `telemetry` | Deterministic retained-metadata fixtures: one bounded page returns exactly the recorded rows, and a history gap is named rather than skipped silently. |
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
+24 -2
View File
@@ -204,7 +204,7 @@ Startup content is merged additively through layers. Each layer adds to what the
4. Rig layer — from the RigSpec's top-level startup block
5. Pod layer — from the pod's startup block
6. Member layer — from the member's startup block in the RigSpec
7. Operator layer — added by OpenRig: openrig-start.md and, on a fresh start, the onboarding pack
7. Operator layer — added by OpenRig: openrig-start.md, openrig-context-entry.md and, on a fresh start, the onboarding pack
```
A member with a `starter_ref` gets that agent starter's files in front of the agent layer. Terminal seats
@@ -220,7 +220,7 @@ A member with a `starter_ref` gets that agent starter's files in front of the ag
| Rig | Rig spec author | Rig-wide context for all agents | Project documentation |
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); `openrig-context-entry.md`, a pointer to the skills router, help and declared context, for every seat; on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
After the files, OpenRig sends a session-identity message (rig, pod, member, logical ID) once the seat is ready.
@@ -423,8 +423,30 @@ running seat only through the action in the last column.
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi`, `codex` and `claude-code` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry or Claude executable (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed when wrapper support is unavailable; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Guidance blocks (`CLAUDE.md`, `AGENTS.md`) | The seat's startup selection, which OpenRig merges into its guidance file as managed blocks at each start and restore | `rig seat status` **Guidance**: when OpenRig wrote each of its blocks, before or after the session's recorded startup finish (for a claimed session, when OpenRig recorded it). That's chronology, not what the agent has: the harness reads the file earlier in startup, Claude Code reloads it after `/compact` or `/clear`, Codex reads it at session start, and the agent can read it, or be sent it, at any time. OpenRig doesn't record what an agent read | Claude: its next compaction, `/clear` or restart. Codex: its next start. Without either, [tell it](#new-guidance-for-a-running-seat) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
### New guidance for a running seat
There are three ways for a seat to pick up new guidance:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
@@ -0,0 +1,14 @@
# Install as $OPENRIG_HOME/community-harnesses/omo.yaml.
# OmO Native (npm omo-ai 5.1.48) on the senpi engine — a Pi fork speaking Pi's RPC protocol.
# With protocol: pi-rpc, OpenRig runs OmO through its Pi runner: structured activity, prompt/steer,
# transcript isolation, and session file resumption.
format: 1
name: omo
command: omo
maintainer: Your name or team
protocol: pi-rpc
resume: true
limits:
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
- credentials resolved via omo's agent directory or models.json
- models declared via provider/id format (e.g. zai/glm-5.3-flash, openrouter/...)
+55 -1
View File
@@ -53,6 +53,7 @@ startup_assisted: true # optional: startup submission needs a person's
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)
# launch_wrapper: true # optional: a runner applies OPENRIG_LAUNCH_WRAPPER to its native child (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
@@ -164,6 +165,10 @@ and reports what it's doing, as OpenRig's own Pi runner does. The seat's activit
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.
- **`launch_wrapper: true` declares wrapper support and requires `runner: true`.** A member with a wrapper and a runner preset without this flag is refused at preflight and launch (not schema validation):
`launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.` See the
[harness and runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch)
for the environment handoff, native-child execution and refusal requirements.
**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
@@ -188,6 +193,37 @@ one at a time and in order, without making the agent wait. It finds the address
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## Pi-compatible RPC harnesses (`protocol: pi-rpc`)
For a community harness that is a fork of Pi or speaks Pi's CLI and JSONL RPC protocol (such as OmO Native on senpi or Prime Agent), set `protocol: pi-rpc`.
```yaml
format: 1
name: omo
command: omo
maintainer: Your name or team
protocol: pi-rpc
resume: true # only after you've checked a real reopen
limits:
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
```
- **OpenRig runs the harness through its existing Pi runner.** Instead of screen scraping, the harness runs under `pi-runner` inside the seat's pane. The runner provides input buffering, paste framing, prompt/steer RPC queueing, clean transcript mirroring, and typed activity hook reporting to the daemon.
- **`command` is the executable.** OpenRig resolves `command` (e.g. `omo` or `/usr/local/bin/senpi`) against the seat pane's environment, resolving symlinks and version-manager shims (such as `mise`) safely.
- **Session identity and resume come from the runner.** Transcripts and sessions are persisted as JSONL files under `$OPENRIG_HOME/state/pi/<session>/sessions/`, and the fork's `get_state` reports the session file, so `launch_result` and `names_conversation` aren't needed.
- **With `resume: true`** declared, resuming or restoring a seat continues that exact session file via `--session <file>`. Without it, the preset is launch-only; a restore that would resume its saved conversation waits for a fresh-start decision.
- **A fork that reports no session file** reads as needing attention, and the launch doesn't read ready.
- **A resume that comes up on another session file** is refused, and that file's identity isn't recorded, so the seat keeps the one it asked for. The seat reads as needing attention.
- **Launch arguments a fork must accept.** When spawned under `pi-runner`, the executable named by `command` must accept Pi's core CLI arguments:
- `--mode rpc`
- `--session-dir <dir>`
- `--name <session-name>`
- `--approve` or `--no-approve` (depending on seat trust)
- And conditionally when configured or resuming/forking: `--model <provider/id>`, `--thinking <level>`, `--session <sessionFile>`, or `--fork <sessionFile>`.
Forks that reject these flags cannot currently run with `protocol: pi-rpc`.
- **Skills, guidance, and model passthrough.** Skills are projected into `<agentDir>/skills/` and managed guidance into `AGENTS.md`. For a fork that reads Pi's variables (`PI_CODING_AGENT_DIR`, etc.), declared models are passed as `--model provider/id` with provider API key allowlisting, and effort is passed as `--thinking <level>`. Usage is not reported for a fork, and extension dialogs are handled as Pi's (cancelled when an extension requests interactive confirmation in a managed pane).
- **Mutual exclusions:** `protocol: pi-rpc` manages session identity and activity through the Pi runner; it cannot be combined with `runner: true`, `launch_result: true`, `names_conversation: true`, `startup_assisted: true`, or `first_input: true`.
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
@@ -310,9 +346,27 @@ Its [README](community-harness-examples/dsh/README.md) has the setup and a check
and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner`, `passes_model` or `launch_wrapper` is refused by an older
OpenRig that doesn't know the field.
## Community-maintained runner: nodecode (@smota)
A runner preset for [nodecode](https://nodecode.ai), a self-modifying Common Lisp agent with a headless JSON-lines mode
(`nodecode rpc`). Listed here so it can be found; it isn't certified, shipped with OpenRig or supported by OpenRig's maintainers.
| | |
|---|---|
| Maintainer | [@smota](https://github.com/smota) |
| Repository | [smota/openrig-nodecode](https://github.com/smota/openrig-nodecode), Apache-2.0, no npm dependencies |
| Tested with | Unreleased OpenRig `main` at `7078c754d` (its package version reads 0.7.1), built from source without fork-only commits; nodecode `0.1.13+98dd6dc80`; Node 22.23.4. Not tested on a released OpenRig (none knows `launch_wrapper`). |
| Needs | an OpenRig that knows `launch_wrapper` (this file documents it). OpenRig 0.7.10 and earlier refuse the preset with "unknown field launch_wrapper". |
| Test record | connector `d8dd4e6`: 95 unit checks (fake and native nodecode), `npm run test:live` (6 steps against the author's daemon built from that `main`) and an opt-in native permission-question proof with a seeded `:NOBODY` failure; results are the author's, not reproduced by OpenRig |
| Declares | `launch_result`, `runner`, `resume`, `first_input`, `passes_model`, `launch_wrapper` |
| Resume | `nodecode rpc <session>`; a session the gateway doesn't know is refused with `error: "missing"` (exit 3) and never replaced. The installer adds `--resume-policy last-completed`: when the session's last turn failed or was cancelled, the runner moves the head back to the last completed turn before taking input, so a resumed seat continues from its last good state; `--resume-policy head` (the bare runner's default) resumes at the head as left. Pass either flag to `scripts/install-preset.mjs` to choose. |
| Reports | `active`, `PreToolUse`, `Stop` and `session_identity` to `/api/activity/hooks`; permission questions through `/api/activity/permission-requests` |
| Fork commits | none needed. Stock `main` accepts the runner's `session_identity` report (the hook answers ok and the identity event is published) but does not persist a community resume token from it; the seat's identity reads unconfirmed, as for any community harness. Two fork-only daemon commits (`be17fa528`, `cbb109b62`) add that plus model and context readings, and are not part of this entry. |
| Limits | no turn cancel; permissions bridged from nodecode's `organism.questions` only, and that proof is driven by a model prompt, so it is opt-in; a question reaches OpenRig only when every choice reads as allow or reject (once or always), others are answered in the pane by typing the exact choice, and only the first four choices are offered through OpenRig; model, provider and effort apply to fresh sessions, a resumed one stays pinned; its gateway keeps running after `rig down`; the installed preset runs each seat in its own nodecode profile (`--seat-profile auto`, sharing the operator's memories and skills), while the bare runner uses the operator's organism unless `--profile` is set; `node` must be on the pane's `PATH` |
## Pass the first startup input at launch
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
+19 -3
View File
@@ -44,14 +44,14 @@ in the case named:
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. In `stuckSweep`, `findingsRouted` counts the findings created since the daemon started (an open finding a later pass finds again is not counted again), `findingsOpen` is how many finding rows were still open after the last pass, and `lastOutcome` is `findings` when the last pass created, found again or closed one. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`, `pair-decision`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
@@ -61,7 +61,9 @@ The daemon reads two tokens from its environment at start:
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it. `rig host
approve|deny` and the TUI's answers to asks send `OPENRIG_AUTH_BEARER_TOKEN` from their environment (`rig host` also
takes `--bearer`), and otherwise the terminal token; refused for want of it, they say what to set.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
@@ -208,3 +210,17 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
- **Queue state counts on `GET /api/slices`:** every list row has `queueCounts`, a current, uncapped count by
`pending`, `in-progress`, `done`, `blocked`, `failed`, `denied`, `canceled` and `handed-off`; it is `null` when
the row's project/mission scope cannot be resolved or catalog/candidate-root discovery is incomplete. This is
separate from the existing `qitemCount` display membership field. Counts match exact typed `project:`,
`mission:` and `slice:` tags in one shared queue scan. Physical candidates include configured indexer roots
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
resolution does not change the returned slice list or filters.
+6 -4
View File
@@ -44,13 +44,15 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
`delegates_to` also names a seat's orchestrator for the queue: the source of the `delegates_to` edge into a seat. The
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
with no incoming `delegates_to` edge has no default orchestrator. A seat with several incoming `delegates_to` edges has
no single orchestrator either: its recovery goes to its declared `escalates_to` destination, or keeps the existing
fallback, and the recovery says the parents were ambiguous. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
A seat with **no declared `delegates_to` parent, or several**, may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A
single stopped or unbound declared parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
+19
View File
@@ -76,6 +76,25 @@ go to:
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## The default receiving agent
The default person is who OpenRig asks. The default receiving agent is the agent that reads what a person answers when
nothing else reads it: a request with no owner of its own, such as a host pairing request or a mode's scheduled end, and
a Slack message no thread or channel route claims. It is:
1. the agent chosen in `agents.operator_session` (`rig config set agents.operator_session <seat>@<rig>`; a person's
address there is ignored);
2. otherwise the kernel operator, `operator-agent@kernel`, when this host has that seat, running or stopped;
3. otherwise none, and the request says no agent reads answers on this host.
A stopped receiving agent stays the receiver: answers wait for it, and the person is told it isn't running. OpenRig
never picks whichever seat happens to be running. The TUI shows the effective agent, where it came from and its state
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
messages to the default receiving agent, read when the connector starts. With no receiving agent on the host, they go to
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
+41 -12
View File
@@ -23,12 +23,19 @@ OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Ma
## 1. System Topology & Custody Boundaries
### Outbound Custody & Recovery
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL` (in `push` mode) or holding the decision for connector pull (in `pull` mode).
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first delivery pass, with no self-imposed outbound 64 KiB cap or multi-part splitting.
- **Push mode (`OPENRIG_RELAY_MODE=push`, default)**:
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`.
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
- **Pull mode (`OPENRIG_RELAY_MODE=pull`)**:
- Client-only connectors that do not bind a listening HTTP server set `OPENRIG_RELAY_MODE=pull`. A pull profile is ready with `OPENRIG_RELAY_INBOUND_TOKEN` alone (`OPENRIG_RELAY_URL` and `OPENRIG_RELAY_OUTBOUND_TOKEN` are optional and unused in pull mode).
- Outbound decisions remain in `DispatchBuffer` awaiting connector pull and are never treated as failed push destinations (`relay-owner-notification-transport-failed` is not recorded). While the outbox lists a notice (staged in `DispatchBuffer` and `relay-attempted-decisions.jsonl` for its `connectorRef`), it is not a missing post for up to **24 hours** (`PULL_OUTBOX_NEVER_POSTED_WINDOW_MS`) and reads `never-posted` (`undelivered-human-notice`) if still unacknowledged after that. A notice the outbox does not list (never staged, staging failed, or its `DispatchBuffer` or seen stores are unreadable, so the outbox answers `503`) uses the usual 120-second window (`NEVER_POSTED_WINDOW_MS`). This is reporting only: nothing is held, refused or re-sent.
- The connector lists its pending complete envelopes via `GET /api/gateway/relay/outbox` (read-only; no consumer leases, read receipts, or required polling cadences) and acknowledges durable custody via `POST /api/gateway/relay/outbox/ack`.
- An acknowledgment on `POST /api/gateway/relay/outbox/ack` has the exact same custody meaning as a push `2xx`: OpenRig records `relay-owner-notification-accepted` (closing informational `update` items as delivered), marks its local outbound seen/delivered guards, and drains the decision from `DispatchBuffer`. Duplicate acknowledgments reconcile idempotently (`alreadyAccepted: true`), and retries after a partial local write failure finish the remaining receipt and drain sequence.
- **Custody boundary (both modes)**: Bridge acceptance (via push `2xx` or pull `POST /api/gateway/relay/outbox/ack`) confirms durable custody of the complete envelope by the connector only (projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not mean posted, seen, answered, or approved.
### Inbound Custody & Resolution
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
@@ -54,21 +61,29 @@ Each relay connector loads configuration from an absolute file referenced by the
### Required Secrets Keys
**Push mode (`OPENRIG_RELAY_MODE=push`, default when omitted):**
```ini
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
```
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
**Pull mode (`OPENRIG_RELAY_MODE=pull`):**
```ini
OPENRIG_RELAY_MODE=pull
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
```
- `OPENRIG_RELAY_MODE`: Optional mode selector (`push` or `pull`; defaults to `push` when omitted). Any other value is rejected as `not-ready`.
- `OPENRIG_RELAY_URL`: Required in `push` mode; ignored/unused in `pull` mode (`endpointUrl: null`). When required, must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Required in `push` mode; ignored/unused in `pull` mode. Non-empty Bearer token sent by OpenRig on outbound HTTP `POST` requests to the bridge.
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required in both `push` and `pull` modes to authenticate connector requests (`GET /api/gateway/relay/outbox`, `POST /api/gateway/relay/outbox/ack`, and `POST /api/gateway/relay/inbound`). Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single `push` profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
---
## 3. Wire Protocol Specifications
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>` or `GET /api/gateway/relay/outbox`)
```json
{
@@ -93,6 +108,13 @@ OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
### Pull Outbox Endpoints (`GET /api/gateway/relay/outbox` & `POST /api/gateway/relay/outbox/ack`)
Both endpoints require a direct loopback TCP peer (`127.0.0.1/8` or `::1`), reject browser cross-origin headers (`403`), and authenticate the caller via `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` (`401`). If the human registry, `DispatchBuffer` (`pendingStrict()`), or outbound `SeenStore` cannot be read, both return `503` (`human_registry_unavailable`, `dispatch_buffer_unavailable`, `seen_store_unavailable`, `queue_store_unavailable`, or `receipt_write_failed`) rather than returning an empty `deliveries` array.
- **`GET /api/gateway/relay/outbox?limit=<1..200>`** (default `50`): Returns `{ "ok": true, "connectorRef": "<ref>", "deliveries": [ <RelayOutboundEnvelope>, ... ] }` with complete pending envelopes from `DispatchBuffer` belonging to the authenticated `connectorRef` in FIFO buffer order. Strictly read-only (no leases or read receipts).
- **`POST /api/gateway/relay/outbox/ack`**: Closed JSON object `{ "deliveryId": "<decisionId>" }` accepting any non-empty string `deliveryId` the outbox can list, within the 64 KiB request-body size bound (`413 payload_too_large`); unknown fields or an empty or non-string `deliveryId` return `400 invalid_envelope`. Finishes any remaining `relay-owner-notification-accepted` transition receipts, marks `slack-outbound-seen.jsonl` and `relay-delivered-decisions.jsonl`, releases the in-memory outbound poll guard, and drains `deliveryId` from `DispatchBuffer`. Returns `200 { "ok": true, "deliveryId": "<id>", "connectorRef": "<ref>", "alreadyAccepted": false }` (`"alreadyAccepted": true` on duplicate ack for the same `connectorRef`, or `404 unknown_delivery` if unknown or owned by another connector; duplicate and unknown acks are answered from `relay-delivered-decisions.jsonl` and `relay-attempted-decisions.jsonl`, and the transition receipt scan runs only when those disagree). On any `503` partial failure, a retry with the same `deliveryId` finishes the remaining receipt and drain steps.
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
```json
@@ -172,7 +194,10 @@ rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/ope
```
> [!NOTE]
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
> `rig gateway human set alice binding.<n>` accepts either a full binding specification string or `unset` to remove that binding slot:
> - **One-primary rule:** No replacement primary is chosen silently. If removing a `primary` binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (`kind:connectorRef:secretsRef:role[:handle=<id>]`) from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` (or `rig gateway human show <entityId> --json`, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role `primary` (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>`, then finish at `binding.0` (once all secondaries are gone, the primary is at `binding.0`).
> - **Shifted slot indexes:** Removal returns `remainingBindings` with their new `binding.<n>` slot numbers (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift down after removal, do not blindly retry `binding.0 unset`; inspect `remainingBindings`, the operation receipt, or `rig gateway human show <entityId>` before retrying.
> - **What removal does:** Removing a binding keeps the person, their preferences, their other bindings, and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close, or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted. A person with no binding needs a daemon from the release that accepts one (v0.7.9+); do not roll back past it while any fragment has `connectorBindings: []`.
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
@@ -203,7 +228,11 @@ rig daemon status
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
### Step 5: Removing Bindings or Participants
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
To remove a connector binding while keeping the participant, their preferences, their other bindings, and their open questions, unset the binding slot:
```bash
rig gateway human set alice binding.0 unset --reason "retire relay bridge"
```
Before removing a participant altogether, ensure any pending queue items and conversations assigned to them are resolved:
```bash
rig gateway human remove alice
```
+18 -3
View File
@@ -201,11 +201,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.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/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
@@ -816,6 +816,19 @@ option, the seat's agent spec for the target runtime, or the runtime's own
default), what is cleared, and the costs below. Nothing changes until you run it
without `--dry-run`.
Use `--runtime pi` to move a Claude Code or Codex seat to Pi; use `--runtime
claude-code` or `--runtime codex` to move back. Pi uses a fresh managed state
directory. Select a prepared Pi account with `--account-home /absolute/path`
(the directory must contain readable `auth.json`), or use the target's configured
login. A source account override or Claude token is cleared only at commit;
credentials are never converted between runtimes. Pi-to-Pi account changes keep
Pi settings, but cross-runtime switches resolve settings afresh for the target.
A configured `launch_wrapper` carries across runtime switches among Pi, Codex
and Claude Code. It must support each successor executable and preserve that
runtime's credential environment. A wrapped Pi seat cannot switch to Claude
Code; set `launch_wrapper: []` in the authored spec and re-apply it first.
The new runtime takes over before the old one stops. Its successor starts beside
the current occupant, reads a handover (the seat's recap, its open rows and the
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
@@ -847,7 +860,7 @@ What it costs:
again afterwards to reach whoever holds the seat.
- **Leftover files after a failed switch.** The target runtime's prepared seat
storage and projections can remain.
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
- **Codex, Claude Code and Pi, single-pane seats.** Startup slash commands aren't
run by a switch; the dry run lists them.
## Stop your teams
@@ -886,6 +899,8 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
| `rig status`, `rig terminal open` or `rig daemon start --wait-for-kernel` says a seat "needs attention" or "is waiting on a person" | The daemon flags that seat, and the line gives its recorded reason: a startup that failed or stopped at a sign-in, trust or other gate, a held message, a missing required file, or a question. "Is waiting on a person" means the daemon sees a live question you can answer in that seat's terminal. Look at it with `rig capture <seat>` or in its tile, then follow the row above that matches its message. "Couldn't check" means the check itself failed; it says nothing either way. |
| A Codex seat, including the kernel's operator, answers every turn with a model error such as "… not supported when using Codex with a ChatGPT account" | Its Codex sign-in works, but that account can't run the model; the kernel picks its runtimes from the providers chosen at setup and their sign-ins, not from which models an account can run. If Claude Code is signed in, move just that seat: `rig seat set-runtime <seat> --runtime claude-code --reason "<model> isn't available to this Codex account"` (`--dry-run` first). Otherwise give it a model the account can run with `rig seat set-model <seat> --model <id> --reason "…"`, then start it again with `rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. |
| 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 | 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. |
+52 -10
View File
@@ -39,14 +39,19 @@ This chapter covers **which agent runs in a seat and how OpenRig talks to it**.
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?
## 1. Plain preset, runner preset, or OpenRig-hosted protocol runner?
| | 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 |
| | Plain community preset | Runner preset (`runner: true`) | OpenRig-hosted protocol runner (`protocol: pi-rpc`) |
|---|---|---|---|
| **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) | OpenRig's own Pi runner, which starts your binary (`command`) in Pi's RPC mode |
| **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 | From the Pi runner's reports, through the same channel as built-in Pi; identity is the session file Pi's `get_state` reports |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt; a message sent mid-turn waits | Pi's input: a submitted block is a prompt, a message sent while busy steers the current turn, `/followup` queues after it and `/abort` cancels it |
| **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 | Your harness is a Pi fork, or speaks Pi's CLI and JSONL RPC protocol |
**For the third shape, you promise your binary's Pi-RPC compatibility at a version you name in `limits`.** OpenRig doesn't
test each fork. It can't be combined with `runner`, `launch_result`, `names_conversation`, `startup_assisted` or
`first_input`. The flags it must accept are in
[Pi-compatible RPC harnesses](community-harness-presets.md#pi-compatible-rpc-harnesses-protocol-pi-rpc).
**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.
@@ -78,7 +83,20 @@ limits:
- **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`.
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`;
- `launch_wrapper: true` declares that the runner applies a member's configured wrapper to its native child.
**Launch wrapper.** `launch_wrapper` is an optional boolean in format 1 and requires `runner: true`. When the member
has a wrapper and its runner preset declares `launch_wrapper: true`, OpenRig passes `OPENRIG_LAUNCH_WRAPPER` as a JSON array of resolved argv strings.
The runner must exec its native child as `<argv...> <agent> <args>`, preserving argument boundaries, and keep its own
process outside the wrapper. The runner must remove `OPENRIG_LAUNCH_WRAPPER` from the environment it passes to the wrapper
and native child; the handoff belongs to the runner only. The wrapper must `exec` the agent in place. If wrapping fails,
the runner must never fall back to an unwrapped spawn. A runner preset without the flag is refused at preflight and launch (not schema validation) when a wrapper is
configured: `launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.`
A plain preset needs no flag: OpenRig prefixes its command
immediately before the preset's native command, after OpenRig's launcher assignments (for example,
`/usr/bin/env OPENRIG_LAUNCH_RESULT=… <wrapper> <preset.command>`), at the same seam as Codex. OpenRig never sets
`OPENRIG_LAUNCH_WRAPPER` for a plain preset and removes any inherited value.
## 3. Identity: report the conversation once, in the launch record
@@ -280,9 +298,31 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
| closes it with no answer at all (a drop) | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
| answers in other words that nobody decides on | after 0.7.11, **the request waits**: the words are kept, never the default permit, until an explicit decision (`rig seat decide-permission`) or the turn's end |
| 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. |
**After 0.7.11, a person's answer goes to an agent that decides, or waits:**
- **Who decides:** this host's receiving agent (`agents.operator_session`, else the kernel operator
`operator-agent@kernel`), unless it's the runner that asked (the request's first row, however it was handed on) or
a seat this host doesn't have; then the kernel operator, unless it's the runner that asked.
- **What happens to the answer:** a click, a plain yes or no, other words, or Steering's Approve or Deny on the request
(a yes or a no) is kept on the row it was given on. The request is handed to that agent with the person's whole
answer, the question and the options, and the person is told once who decides and in what recorded state.
- **The runner keeps reading `pending`.** The agent decides with `rig seat decide-permission <askId> --option <id>
--reason "…"` (`POST <base>/api/activity/permission-requests/<askId>/decide` with `{"optionId", "reason"}`).
- It accepts only an option the agent offered.
- It records an agent's decision: who decided, why, and the person's words.
- A request that's no longer pending comes back as it is, and nothing is written.
- **A kept answer counts only through an explicit decision.** If the row is closed without one (an agent's or a
person's ordinary close), the answer stays kept and the request waits; `rig seat decide-permission` still settles it.
A refusing close (denied) is still a no.
- **With no agent that can decide,** any answer is kept, the request waits, and the person is told once that the tool
hasn't run and how to decide directly (`rig seat decide-permission`) or set up a receiving agent. A handoff that fails
leaves the request waiting the same way.
- **No timeout refuses a request.** If the turn ends first, nothing is sent.
- **The default permit stays** for a request nobody can be asked about (above), and one closed with no answer at all.
- **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
@@ -327,7 +367,9 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
`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.
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome. After 0.7.11, words are never
answered with the default permit (they keep the request waiting), so `said` no longer arrives. A runner that handles
it keeps working.
- **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
+5 -1
View File
@@ -91,7 +91,11 @@ and `severity=info`. CLI/TUI call this **needs diagnosis**, not a confirmed
warning. Enabled diagnosis policy admits one bounded packet even though the
product denominator is unknown. Ordinary unavailable/stale detector records
remain ineligible. Diagnosis and human-notification traffic are excluded from
the numerator and cannot recursively generate another investigation.
the numerator and cannot recursively generate another investigation. So is the
daemon's own bookkeeping: transitions by system actors (`*@system` and the
bare `system` of claim handover) and daemon notes recorded under any actor
(`auto-unparked:`, `blocker custody moved `, `park wake `, `parked-owner `).
The reported `transitionIds` are the counted transitions.
The agent reads the actual evidence, including beyond the supplied references,
and resolves the semantic outcome granularity and consequence boundary. Use the
+18
View File
@@ -97,6 +97,24 @@ missing tools or logins, kernel startup, a closed viewing terminal and recovery
before choosing an action. A healthy daemon doesn't by itself mean the project's seats are ready: check
`rig ps --nodes --rig <rig-name>`.
If the whole team stopped when a herdr terminal closed, OpenRig or its tmux server was started from inside that
terminal: herdr-core ends every process started there when the terminal closes. `rig daemon start` no longer passes a
herdr terminal's scope on, but processes started before that keep it until they restart. OpenRig never restarts them
for you; restart at a time you choose, from a terminal outside herdr.
With `terminal.tmux_server` at its default (`inherit`), OpenRig puts seats on the tmux server of the shell that
started it, usually your own default server. If that server was already running, and you had started it from a herdr
terminal, it carries that terminal's scope. OpenRig can't prevent this, because the server is yours.
The same applies when a command like `rig chatroom watch`, run in a herdr terminal, starts the default server. Start
tmux outside herdr, or use `terminal.tmux_server isolated` (see `tmux-servers.md`), which gives OpenRig a server it
starts itself.
The daemon keeps `HC_SESSION` and `HC_SOCKET`. The herdr-core they select is the one the herdr-app provider starts
viewers in; `rig terminal status --provider herdr-app` names it (`core` and `assumption`), as do open failures
(`viewer_start_failed`, `herdr_app_unavailable`) and the note when a window has not confirmed showing a view. If your
herdr-app window is attached to a different herdr-core than the daemon's, its viewers will not appear there; start the
daemon with that core's `HC_SESSION` and `HC_SOCKET`.
### The agent is waiting for permission or can't reach the daemon
Read [Have your agent configure permissions](getting-started.md#have-your-agent-configure-permissions)
+145
View File
@@ -0,0 +1,145 @@
# OpenRig views in herdr-app
herdr-app (Herdr 2, the desktop app on herdr-core) can show OpenRig's terminal views inside its own window. A view
opens as a **Rig view**: an equal grid of the view's seats, each with a title row (`<agent> · <slice>`), padded to a
full grid. Rig views live in OpenRig's own herdr-app layout, **Rig** (`layouts/rig.json`), beside your tabs. A strip
above them switches between **Your tabs** and each view.
Nothing here needs a desktop display or SSH: the view opens in the herdr-app window the request came from.
herdr-app has to run on the same machine as the OpenRig daemon: OpenRig reaches it through its control socket
(`<app home>/herdr-app.sock`) and writes its mods and layout into that app home. An app on another machine, connected
to this machine's herdr-core over SSH, isn't supported.
## Set up
Nothing to install by hand: opening a view installs OpenRig's four mods (rig-strip, rig-cell-header, rig-pad,
rig-ended) and updates them when OpenRig is updated.
- The mods go to `<app home>/mods/rig-*`. The app home is herdr-app's own: `HERDR_APP_HOME`, else
`$XDG_CONFIG_HOME/herdr-app`, else `~/.config/herdr-app`. `--app-home <dir>` on `rig terminal open` names another.
- OpenRig owns only the files it ships there (recorded in `<app home>/openrig/mods.json`). An update replaces those
and nothing else in the folder. Mods you install yourself, from any source, are never touched.
- To move between a view's cells from the keyboard, bind the strip's actions in `keys.json`, section `layout:rig`
(your agent can add it, as for any herdr-app key):
`"layout:rig": { "alt-left": "rig-strip.left", "alt-right": "rig-strip.right", "alt-up": "rig-strip.up", "alt-down": "rig-strip.down" }`.
In the Rig layout these replace herdr-app's `global` defaults for the same keys (`pane.go`, `agent.prev`/`agent.next`),
which keep working on your other layouts. A key bound in `terminal` wins while a cell has focus, so pick keys that
aren't bound there. Once you edit `keys.json`, herdr-app treats it as yours and stops adding new default shortcuts.
- An open never replaces a mod you changed: that folder stays as it is, the view opens anyway, and the open's notes
name the folder and why. Only a mod a view needs that is missing and can't be installed stops an open.
## Open a view
- **From the TUI inside herdr-app:** Terminals, choose a view (Enter previews it), then **Open terminals**. The TUI
recognises that it runs in a herdr-core terminal of a herdr-app window on this machine, and the preview and the
open both use that window, so the plan you previewed is the one that opens. The preview says the view opens in
this window; it shows no desktop or SSH recipe. A shared TUI viewed from a herdr-app terminal does the same. The
kernel view opens in its narrow arrangement there (one seat per page): the window's width isn't the TUI's.
- **From a shell:** `--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering. `--window` always asks for a new desktop window.
| Command | Where the view opens |
|---|---|
| `rig terminal open <view> --provider herdr-app [--app-home <dir>]` | In the herdr-app window this runs in (requires the window to answer). |
| `rig terminal open <view>` | Inside a herdr-app terminal when the window is answering, in that herdr-app window; otherwise a desktop window. |
| `rig terminal open <view> --window` | Always asks for a new desktop terminal window, even inside a herdr-app terminal. |
The herdr-app window must be answering on its socket (`<app home>/herdr-app.sock`); otherwise an explicit open fails and says
so. Finding the socket file is how OpenRig guesses you are in a herdr-app window; whether the window is reachable is
checked by asking it. When a plain `rig terminal open <view>` in a herdr-app terminal finds that the socket does not
answer, it says so and opens a desktop window instead; `--provider herdr-app` never
falls back to a desktop window or an SSH recipe.
What an open does, in order:
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over, and the first thing
you type attaches it. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
shell in the cell.
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
of each of the view's interactive cells, on every page (including pages the strip hasn't shown yet, since the strip
moves between pages on its own), and each cell takes input a fraction of a second later (it checks every 0.2 s).
Anything that reached a cell before that, typed or written to its terminal (`hc send`), is dropped, not delivered
late. The open reports a view herdr-app
hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the Rig layout, and its cells
take input once herdr-app shows it. OpenRig keeps watching for 10 minutes, across a daemon restart; if herdr-app
hasn't shown it by then, each interactive cell says so, takes no input, and names the command that opens the view
again (`rig terminal open <view> --provider herdr-app`).
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
## The keyboard
- An open puts the keyboard on the new view's first live cell.
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
you were last in there, else the first live cell.
- **Your tabs** gives the keyboard back to your own pane.
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
a view or in your tabs.
- Typing in an interactive cell, once the view is shown, attaches its tile to the seat; after 30 seconds without a key
it goes back to showing it. When the seat's session is replaced (a handover, a fresh launch, a restart), the tile follows the same seat to
its new session, as every OpenRig tile does.
## What OpenRig keeps in the app home
All under `<app home>/openrig/`:
| File | What it holds |
|---|---|
| `mods.json` | The files OpenRig shipped to `mods/rig-*`, so an update replaces only those. |
| `ack.json` | Written by the strip: the last layout revision herdr-app shows and the terminals it references. A viewer ends only once this shows its cell is gone. |
| `strip.json` | Written by the strip: the cell you were last in per view, and the view shown. herdr-app gives a mod new storage whenever its props change, which is every OpenRig write, so this is kept as a file. |
## Close a view
```sh
rig terminal close <rv-id> [--app-home <dir>] # the id is in the open result's `space`
```
The view's cells leave `rig.json` first. A viewer ends only after herdr-app confirms it shows a layout without it, or
when the Rig layout isn't shown (herdr-app then reads the new file when it is). Only viewers OpenRig started (its
record, its labels and its exact command all match) are ended. Seats are never touched. Anything uncertain is left
running and named in the result.
If a viewer ends some other way (killed outside OpenRig, herdr-core restarted), OpenRig rewrites its cell as an
ended cell straight away. Views OpenRig recorded keep that owner across a daemon restart: when the daemon starts, it
reconciles each app home it has views in (a viewer that ended while it was down is rewritten then) and finishes any
pending ends. If `hc events` can't run (herdr-core missing or down), the watcher tries again after 5 s, then waits
twice as long each time, up to 5 minutes. Stopping the daemon ends only its own `hc events` watcher and timers; viewers
and views stay.
## Which herdr-core
```sh
rig terminal status --provider herdr-app [--app-home <dir>]
```
`core` and `assumption` in the result name the herdr-core the daemon's `hc` runner starts and ends viewers in: the
daemon's `HC_SESSION` (else `default`) and `HC_SOCKET` if set when the daemon started. The herdr-app window must be a
client of that same core. Terminal addresses are per core, so a window attached to another core would show other
terminals, or none, in the view's cells. Open failures (`herdr_app_unavailable`, `viewer_start_failed`) and the note
when a window has not acknowledged a write also name the daemon's core (`HC_SESSION`/`HC_SOCKET`) so a mismatch is
visible straight away.
A status or a preview only reads: it starts no herdr-core process and creates nothing. OpenRig watches herdr-core's
events (`hc events`) and creates its viewers folder only when it opens or closes a view, or when it starts with views
already recorded.
## Limits
- A Rig view is a layout inside herdr-app, not a herdr tab: other windows and other layouts don't show it.
- Following a task (`--follow`) isn't available in herdr-app yet.
- When a viewer ends unexpectedly, herdr-app removes its block and collapses the layout, which briefly opens the Files
panel on that cell's folder until OpenRig's rewrite restores the layout. Viewers start in OpenRig's own empty
folder (`$OPENRIG_HOME/private/herdr-app/viewers`). Planned closes never trigger this.
- OpenRig doesn't yet check that the window runs on the daemon's herdr-core (`core` in `rig terminal status`); it
relies on both using the same session on the same machine.
- `POST /api/terminal/open` (and `POST /api/terminal/close`) carries no bearer middleware, matching the daemon's other
open routes; an open writes `mods/rig-*`, `openrig/mods.json` and `layouts/rig.json` into the named `--app-home` once
`herdr-app` answers a `status` request on `<app home>/herdr-app.sock`.
- Runtime-tested on Linux only; macOS and Windows haven't been tried on real machines.
+19 -7
View File
@@ -86,8 +86,14 @@ entry. The entry holds secret names, never values:
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
the operator seat when it's a person, else the rows' creator when they all share one, else the operator agent, else the
member itself. A notice to a creator lists only that creator's rows: when another creator's row joins, the notice moves to
the next recipient. It says whether the member has read its own pending inbox recently: a check-in, never a receipt or a
claim. A member with a declared route (a delegation parent or `escalates_to`) gets one finding per unclaimed row at that
route instead.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
@@ -95,7 +101,7 @@ These facts stay separate on the row:
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`. With an external grant offering `team/list`, call it without `rigId` to discover the connected team. It selects the grant's sole rig, or returns only its granted names when a choice is needed.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
@@ -129,6 +135,10 @@ and nothing else is offered:
- 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.
- **A first task without a seat address:** with `--task-destination <member@rig>` on the same `rig mcp serve`, a
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
before.
- **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.
@@ -137,8 +147,8 @@ and nothing else is offered:
- **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.
- Reading or listing an item records nothing on the item, and nothing wakes an assistant, so an item stays queued until its
receiver claims it. A member listing its own pending inbox records only a check-in time, which the notice above reads.
### A person answering the team's questions through their assistant
@@ -148,7 +158,8 @@ pair, the person's assistant can read the team's questions waiting on them and s
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
unusable `person` withholds only these two tools while the grant's other tools keep working.
- **Register the person** on the instance, once: `rig gateway human add <entity_id> --display-name "<name>" --default`.
- **Register the person** on the instance, once:
`rig gateway human add <entity_id> --display-name "<name>" --delivery-class B --default`.
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
registered person (such as `human@host`) are theirs too.
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
@@ -179,5 +190,6 @@ unusable `person` withholds only these two tools while the grant's other tools k
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
isn't rung; work it leaves unclaimed past the unclaimed age is listed in one notice instead, which goes to its person, else
its rows' shared creator, the operator agent or the member itself.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one, except through a grant service's grant that names its person, which records that person's own answers (above).
+3 -1
View File
@@ -59,7 +59,9 @@ rig queue block <task> --on external:<what you check> --wake-after 1h \
```
`--on` is always required; `external:<what>` names the outside thing you're waiting on. The timer first fires after
the interval, not now.
the interval, not now. Left without `--wake-after` (or `--wake-watchdog`), the task is still set aside, but nothing
will wake you, and the reply says so with a `park_without_wake` advisory. Set it aside again with `--wake-after` to add
the timer.
**4. Say back what you set, in one or two plain lines:** what you'll check, how often, how they'll be told, and when
it stops. For example: "I'll check the release PR every hour and message you here once it merges. Then I'll stop."
+161
View File
@@ -0,0 +1,161 @@
# Queue erasure: closed-work retention and account deletion
A daemon that serves several accounts sometimes needs to remove queue content for good: closed work after its retention window, or everything that belongs to an account when the account is deleted. `rig queue erase` and three retention settings do that.
Erasure can't be undone. Run every scope as a dry run first, without `--apply`. The dry run prints exactly what `--apply` would erase and changes nothing.
## What is erased
A queue row is erased in one of two ways:
- **Scrub.** The row's content is removed, and the row itself stays, marked `contentErasedAt`. A retried create with the same id still finds the row, so it makes no new work. If anything writes to the row after its scrub (a note, a summary, a reopen and close), the next run erases that content too.
- **Delete.** The row and everything linked to it are removed. Only its id is kept, in an erased-id record with no endpoints and no content. A later create that reuses the id is refused with `qitem_erased` (409), so a retried or forwarded create can't bring the work back.
| A scrub clears | A scrub keeps |
|---|---|
| body, summary, resolution, tags, blocker text, chain of record, evidence reference, human detail, questions and answers, delegated decision, the input-box reading | id, source and destination, state, priority, tier, times, closure reason, handoff links |
| a closure's target when it's a reason or note, in the row, both transition tables and events. That's every target under a reason other than the two at right, and any target under those two that isn't a session address or a queue id | a closure's target that names a successor: under `handed_off_to` or `superseded`, shaped as a session address (`member@rig`, optionally `@host`) or a queue id (`qitem-…`) |
| transition notes and recorded principals, live and archived | each transition's state, actor and time |
| events' `summary`, `reason` and `error` (the event keeps its type and ids, marked `contentErased`) | |
| mission-control audit text and snapshots | |
**The row's own copies go with it:**
- its transitions;
- events whose payload names it;
- its mission-control audit rows.
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. Any other copy is kept and listed under `keptCopies`:
- `mixed`: one of its sessions is the owner's;
- `foreign`: none of them are.
**A row a workflow still references is never erased.** That covers a step trail, a frontier binding, a failure record, or a live instance's frontier. It's reported under `gaps` with the code `workflow-reference`.
## Scopes
Name exactly one.
| Scope | Erases | Action |
|---|---|---|
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
| `--ids <file>` | Exactly the listed rows. A report's `qitems` entries, or a ledger's lines, keep each entry's own action. A ledger's entries are replayed only for the batches that committed (below) | `--action delete` (default for plain ids) or `--action scrub` |
| `--routine` | Closed rows past this daemon's retention windows (below) | scrub, then delete |
A file is one entry per line (blank lines and `#` comments are skipped; a line starting with `{` is a JSON entry) or a JSON array.
**Account deletion is exact.**
- The members file is the account's recorded membership: the exact session names of its seats, including departed ones. List every address form the account used, such as a host-suffixed form. Names are compared as given, never by pattern, so a row addressed through an unlisted form is neither erased nor reported.
- **Mixed rows:** a row with exactly one member endpoint is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
- **After the rows:** account deletion also removes outbox and inbox entries between two members that no row owns, and the members' stream items. An entry with one member endpoint, or a stream item something still references, is counted and left.
**The account's rig.** A rig's own records outlive the rig. Events are kept past rig and node deletion by design, and snapshots, node and seat records and watchdog jobs have no key that cascades. `--rig <id>`, given with `--members`, erases them:
- the rig's events, including those of its nodes;
- snapshots, operation correlations and bootstrap runs;
- seat identity verdicts, delivery-guard changes, environment bindings and usage records for its nodes and member sessions;
- watchdog jobs aimed at a member, with their history;
- stream subscriptions and thread maps of member sessions.
Records that name the account but also belong elsewhere are counted under `rig.left` and kept. These are a member's watchdog job aimed outside the account, a workflow instance or spec bound to the rig, classifier leases and custom views.
- **While the rig exists,** its sessions and the members must match both ways, or the run is refused (`erase_rig_mismatch`). Every member's session name must carry the rig's name (departed members included), and every current session of the rig must be a member, so the rig holds no one else's seat.
- **On that verified run,** the rig's id and name are recorded with a digest of the membership.
- **Once the rig is removed,** the same command, with the same membership, uses that record to erase what's left under the rig's id. This includes the events the removal wrote, and the record still lets it count records bound to the rig's name.
- **When nothing of the rig is left,** the record becomes a completion receipt: the rig's name is cleared, and its id, the membership digest and the time stay. An identical rerun, after a lost response say, still verifies and reports the same clean result, with `rig.completedAt`. The receipt is kept for good. It holds no content.
- **A removed rig's id that was never verified** with this membership erases nothing keyed by it. Those records are counted under `rig.left`, the name-bound tables are listed under `rig.unknown`, and the run isn't clean.
**Retention windows** (`rig config set …`, read at the daily sweep and at daemon start):
| Setting | Default | Effect |
|---|---|---|
| `retention.closed_content_days` | `0` (off) | Scrub closed rows this many days after they closed |
| `retention.closed_metadata_days` | `0` (off) | Delete closed rows, keeping only their ids, this many days after they closed |
| `retention.erasure_ledger` | empty (none) | An absolute file path, on local disk, since each batch appends to it while it holds the daemon. Every applied erasure, by the sweep or the command, appends each row's id, action and batch id there, one JSON line each, before anything in its batch changes, then a commit line once the batch commits (an abort line if it rolls back). A path that can't be written stops the run before it changes anything |
- **Closed** means `done`, `canceled`, `denied` or `handed-off`. `failed` and open work are never touched by the windows.
- **Age:** a row's age counts from when it entered its final closed state. A note on a closed row doesn't restart it, and a row reopened and closed again counts from its latest close.
- **Running them now:** `rig queue erase --routine --apply` runs the windows immediately. `retention.enabled false` turns off the whole sweep, these windows included.
- **When the sweep's erasure stops on an error,** that's logged, and the sweep's other passes still run.
## Account deletion, in order
1. Revoke the account's access.
2. Keep its exact membership list and rig id.
3. Run `rig queue erase --members <file> --rig <id>` as a dry run, then with `--apply`. Rerun until `complete` is true.
4. Remove the rig.
5. Rerun the same command with `--apply`, to erase the records the removal wrote.
6. The account is deleted only when the report's `clean` is true. Otherwise its `mixed`, `gaps`, `keptCopies`, `unlinked` and `rig.left` or `rig.unknown` entries need a decision. Rerunning won't resolve them.
## Output
The command prints one JSON report (`--json` makes it a single line):
| Field | Meaning |
|---|---|
| `applied` | `false` for a dry run |
| `complete` | the run finished. `false` when it stopped at its batch limit or on an error; rerun the same command to continue |
| `clean` | `complete`, and nothing was left: no `mixed` rows, mixed `keptCopies`, `gaps`, `unreadableEvents`, left-over entries, or rig records left or unknown. Only a clean report means the scope is fully erased. It's about this run's scope: a routine or members run's `clean` isn't affected by the ledger's other batches, and only a replay (`--ids` with a ledger) reports a batch it can't show committed, as the gap `ledger-unresolved` |
| `error` | why the run stopped (`stage` and `message`). Everything listed in `qitems` was committed before it; the command exits 2 |
| `qitems` | each row erased (or that would be), with its `action` and `state` |
| `mixed` | members scope: rows with one member endpoint, left untouched |
| `keptCopies` | copies of erased rows held with someone else, kept: `table`, `id`, `qitemId`, `ownership` (`mixed` or `foreign`) |
| `gaps` | rows not erased and why: `workflow-reference`; `not-found` for an id to scrub that doesn't exist; `changed-during-run` for a row that no longer qualified when its batch ran (reopened, rerouted out of the account, or written to so it left its window) or an absent id created since selection; `unreadable-event` for a row an unreadable event names; `ledger-unresolved` for a replayed entry whose batch can't be shown to have committed |
| `skipped` | ids scope, a ledger replay: entries whose batch rolled back, with the batch id; not replayed |
| `ledger` | an applied run with a ledger: batches whose missing commit line it wrote from the database's record (`reconciled`) |
| `alreadyAbsent` | ids to delete with no row; their erased-id record is written (again), and they're listed once it's committed. They're rechecked as the record is written, so an id created during the run is reported as `changed-during-run` instead |
| `unreadableEvents` | events naming a selected row whose payload can't be read: `seq` and the row ids found in its text. They're kept, since what they hold can't be inspected, and so are the rows they name, so every rerun reports them again until the event is repaired or removed. The run isn't clean |
| `linked` | counts of the copies erased with the rows |
| `unlinked` | members scope: entries and stream items erased that no row owned, plus those left (`mixedEntries`, `referencedStreamItems`) |
| `rig` | members scope with `--rig`: `found`, `verified`, records erased per table (`erased`, counted as each chunk commits), records left (`left`), tables that can't be counted (`unknown`), and `completedAt` once the rig's erasure is complete |
**How a run works:**
- **Selection:** a run selects everything first and writes nothing.
- **Events:** it reads the events table once, in small windows, to find each row's events.
- **Batches:** each batch, of up to 100 rows, is one transaction. It rechecks its rows, writes their ledger lines, then changes their events, copies and rows.
- **The recheck drops** a row that a workflow has taken up, or that an unreadable event names. For account deletion, it also drops a row that is no longer between two members. For a routine run, it drops one that was reopened or no longer falls in the same window.
- **Each dropped row** is reported in `gaps`.
- **The current endpoints** decide whose its copies are.
- **How long it holds the daemon:** the batch caps rows, not events or copies, so a row with many of either makes its batch longer. On a copy of a store with 2.4 million events, the longest single hold in a first routine run was about 0.25 s. With a ledger, a run doesn't read the ledger file: it reads the end of the file (one byte) and the database's record of batches still missing a commit line, so the ledger's size doesn't add to the hold.
- **Stopping:** a run that stops leaves whole batches done, events included. A rerun picks up the rest, and erasing a row a second time changes nothing.
- **Ledger lines and commits:** a batch's lines are written before its first change, so they can name rows that a rolled-back batch never erased. Each batch therefore also records its id in the database, in its own transaction, and appends a commit line (`{"batch": …, "committed": true}`) after it commits, or an abort line (`"committed": false`) when it rolls back.
- **A missing commit line:** a run that can't write one stops once the committed batch is reported. The database's record notes when each batch's commit line was written. Each applied run with a ledger writes the commit line for any recorded batch still without one, from the record alone, without reading the ledger file. It lists those under `ledger.reconciled`.
- **Neither line nor record:** a batch with neither, such as one whose transaction was cut off by a crash, is never decided from the database: on a restored database, a batch committed after the backup looks exactly like one that rolled back. A replay reports its entries as `ledger-unresolved` and doesn't apply them, until an operator resolves it by appending its commit or abort line once they know which.
- **A line cut short:** a full disk, say, can cut a line short. Each run starts the ledger on a fresh line first, so the cut line can't fuse with the next one. A replay refuses the file until that line is deleted. That's safe: an entry line's batch rolled back when its write failed, and a commit line is written again from the record.
**Refusals**, with nothing erased:
| Code | Cause |
|---|---|
| `erase_scope_required` | no scope named |
| `erase_scope_conflict` | more than one scope |
| `erase_members_invalid` | an empty list, or names with surrounding spaces |
| `erase_rig_invalid` | a rig without `--members`, or an empty rig id |
| `erase_rig_mismatch` | the rig exists, and its sessions and the members don't match both ways |
| `erase_ids_invalid` | an empty ids list, or ids with surrounding spaces |
| `erase_action_invalid` | an action other than `delete` or `scrub` |
| `erase_routine_off` | both windows are `0` |
The command records who ran it from `OPENRIG_SESSION_NAME`, or from `--actor <session>` when that isn't set.
## Restores and backups
A restored database can bring erased content back: rows, copies, rig records and the erased-id records themselves. It can even bring back a row that was still open when the backup was taken and was closed and erased later. Before a restored daemon serves anyone, replay what was erased after the backup was taken:
1. **Keep a ledger.** Set `retention.erasure_ledger` to a file outside the backed-up data. Every applied erasure appends its ids, actions and batch there before changing anything, and a commit line once the batch commits. That covers the daily windows, `--routine`, `--ids` and account deletion's rows.
2. **Replay the ledger:** `rig queue erase --ids <ledger> --apply`. It replays exactly the batches that committed, each line with its own action, so a scrub is replayed as a scrub, never promoted to a deletion.
- **What's replayed:** a row that was open in the backup and erased later is erased by its id, and other open work isn't touched.
- **What's skipped:** a batch with an abort line was never erased, and is listed under `skipped`.
- **What's left:** a batch with neither line, which the restored database doesn't record either, is reported as `ledger-unresolved` and not replayed. The run isn't clean until an operator decides it.
3. **Replay account deletions:** rerun `rig queue erase --members <file> --rig <id> --apply` for each account deleted since the backup, from its membership list and rig id. Keep those outside the backed-up data until every backup taken before the deletion has expired. This also covers the copies and rig records the ledger doesn't list.
4. **Rerun the windows:** `rig queue erase --routine --apply`.
Backup expiry is the deployment's own schedule. Content erased from the live database stays in backups until they expire.
## What this doesn't cover
These stores need their own handling where a deployment uses them:
- terminal transcripts;
- daemon and service logs;
- gateway and Slack stores and Slack's own copies;
- downloaded files and artifacts;
- chat messages (removed with their rig);
- events tied neither to a row nor to the account's rig.
The rig itself (its nodes, sessions and bindings) is removed by removing the rig, between the two runs above.
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.11`. 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.
+15 -9
View File
@@ -587,9 +587,13 @@ rig provider switch --seat pi-worker@my-rig --to-account work \
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
```
When switching into Pi with `--clear-account-home`, OpenRig restores the seat's original Pi login only if no effective member or rig Pi account home is configured. A member's `PI_CODING_AGENT_DIR` shadows the rig's value; if the member value is relative, it is ignored and the rig home does not take effect.
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
and another runtime is not supported by this account-home feature.
it must preserve the selected credentials if you want OpenRig's account choice to apply.
Use `rig seat set-runtime <seat> --runtime <runtime> --reason "Change provider"` with `pi`, `codex` or `claude-code` for cross-runtime switches.
Those resolve the target's settings afresh rather than keeping the source model or effort;
see [runtime switching](getting-started.md#move-a-seat-to-the-other-runtime).
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,
@@ -691,7 +695,7 @@ first turn. Check `/status` after a real tool turn to confirm the authentication
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.
with the key on stdin, then `CODEX_HOME=<home> rig auth save <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
@@ -779,7 +783,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
| `launch_wrapper` | string[] | no | — | Local built-in `pi`, `codex` and `claude-code` members only. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
| `launch_wrapper` | string[] | no | — | Local `pi`, `codex`, `claude-code` and community-harness members; runner presets must declare wrapper support. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
@@ -917,9 +921,11 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
own defaults/settings apply. Other runtimes ignore this field.
### Launch wrapper (`runtime: pi` and `codex`)
<a id="launch-wrapper-runtime-pi-and-codex"></a>
Set `launch_wrapper` on a local built-in `pi`, `codex` or `claude-code` member to prefix its native executable and arguments with a literal argv vector:
### Launch wrapper (`runtime: pi`, `codex` and `claude-code`)
Set `launch_wrapper` on a local `pi`, `codex`, `claude-code` or community-harness member to prefix its native executable and arguments with a literal argv vector:
```yaml
members:
@@ -931,7 +937,7 @@ members:
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
```
- **Scope and literal argv:** Local built-in `pi`, `codex` and `claude-code` members, including legacy flat nodes, support `launch_wrapper`; other runtimes reject it, including `[]`. Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
- **Scope and literal argv:** Local `pi`, `codex`, `claude-code` and supported community-harness members support `launch_wrapper`; legacy flat nodes support it only on `pi`, `codex` and `claude-code`. Other runtimes, including the maintained `openshell-claude` environment path, reject it, including `[]`. Community preset capability and execution requirements are defined in the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch). Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
- **Executable resolution:** `launch_wrapper[0]` must be an absolute path or bare command name. Preflight and launch resolve it using the daemon's launch `PATH` and working directory, before adding the seat's `rig` bin directory. Relative paths containing `/`, aliases and functions are unsupported for the wrapper itself.
- **Native child and `exec` contract:** The wrapper prefixes the resolved Codex entry, prefixes the resolved Pi child executable inside the host runner, or prefixes the real Claude executable and native arguments. Managed Claude applies it after rebuilding the environment; pinned continuations retain their executable. The wrapper must `exec` its child in place, preserving working directory, environment, signals, exit status and TTY stdio (Claude/Codex) or RPC pipes (Pi). Supervising wrappers are unsupported.
- **Classic Claude lookup:** Wrapped classic launch supports bash, zsh, sh and dash and requires `command -v claude` to resolve to an executable file; aliases and functions refuse with `launch_wrapper unavailable; no agent started.` To keep custom flags or environment, put them in a `#!` script named `claude` on `PATH`; the wrapper runs that script ([#1477](https://github.com/mvschwarz/openrig/issues/1477)).
@@ -1015,7 +1021,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
| `escalates_to` | Fallback queue escalation for a seat without a single declared delegation parent (none, or several). Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
@@ -1358,7 +1364,7 @@ spec can pass `rig spec validate` and then fail at preflight or import. Run
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
27. `launch_wrapper` is valid only on `pi`, `codex` and `claude-code` members (`[]` included); it must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
27. `launch_wrapper` is valid on `pi`, `codex`, `claude-code` and supported community-harness members (`[]` included); see the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) for community capability requirements. It must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
---
@@ -13,6 +13,8 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
- **`--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.
- **`--launch-wrapper`** says your preset declares `launch_wrapper: true` and enables case 10 from the
[runner contract](../harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch).
- **`--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.
@@ -62,6 +64,7 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
| 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 |
| 10 | 2 | The native child runs through the configured wrapper, preserving literal arguments, without inheriting the runner's wrapper handoff |
## What it doesn't check
@@ -6,12 +6,12 @@
// - 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]
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input] [--launch-wrapper]
// [--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.
// `resume: true` and `first_input: true`; --launch-wrapper declares `launch_wrapper: true`. Without a declaration, its case is skipped.
// 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.
//
@@ -27,10 +27,10 @@ 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]";
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--launch-wrapper] [--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 };
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, launchWrapper: 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]; };
@@ -41,6 +41,7 @@ function parseArgs(argv) {
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 === "--launch-wrapper") opts.launchWrapper = true;
else if (flag === "--json") opts.json = true;
else if (flag === "--keep") opts.keep = true;
else throw new Error(`unknown option ${flag}`);
@@ -178,7 +179,7 @@ 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 } = {}) {
async function launch(label, { scenario = {}, env = {}, asks, launchWrapper = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
const cwd = join(dir, "work");
mkdirSync(cwd);
@@ -196,6 +197,13 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
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);
h.agent = agent;
if (launchWrapper) {
h.wrapperLog = join(dir, "wrapper.json");
const wrapper = join(dir, "wrapper.sh");
writeFileSync(wrapper, `#!/bin/sh\nrecord=$1\ntag=$2\nseparator=$3\nshift 3\n${sh(process.execPath)} -e 'const [file, tag, separator, ...argv] = process.argv.slice(1); require("node:fs").writeFileSync(file, JSON.stringify({tag, separator, argv, launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null}));' "$record" "$tag" "$separator" "$@"\nexec "$@"\n`);
env = { ...env, OPENRIG_LAUNCH_WRAPPER: JSON.stringify(["/bin/sh", wrapper, h.wrapperLog, "literal ' $ ; space", "--"]) };
}
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",
@@ -781,9 +789,22 @@ const cases = {
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
return r;
},
async "10"() {
const r = result("10", "Launch wrapper: the runner wraps its native child and scrubs the handoff environment");
if (!opts.launchWrapper) return r.skip("the preset doesn't declare launch_wrapper (--launch-wrapper): OpenRig passes no OPENRIG_LAUNCH_WRAPPER");
const h = await launch("10", { launchWrapper: true });
r.check("the wrapped child opens its conversation", await ready(h), "no launch record");
const seen = existsSync(h.wrapperLog) ? JSON.parse(readFileSync(h.wrapperLog, "utf8")) : undefined;
r.check("the wrapper receives literal arguments and the native child command", seen?.tag === "literal ' $ ; space" && seen?.separator === "--"
&& seen?.argv?.[0] === h.agent, JSON.stringify(seen ?? null));
r.check("OPENRIG_LAUNCH_WRAPPER is absent from the wrapper and child environment", seen?.launchWrapper === null
&& h.events("started")[0]?.launchWrapper === null, "wrapper handoff leaked to native child");
return r;
},
};
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9", "10"];
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); }
@@ -42,7 +42,7 @@ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, me
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) });
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2), launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null });
// 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" }));
+16 -1
View File
@@ -161,10 +161,25 @@ or `pending`, `draft`. A word that matches none of these counts as `active`, so
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
`rig view show execution` shows the slice's own `status:` word (from its
`SPEC.md`, or a legacy `README.md`) and that word's bucket beside each slice's
ladder as `recorded_status` (`word: null` when there's none). Like the scopes
views, it never reads `PROGRESS.md`; `GET /api/slices` lets a `status:` in
`PROGRESS.md` frontmatter override the node file's. It's the team's record, not
verified, so it never turns a rung true or makes a slice next-up.
The ladder's rungs read Part B records: plan lock, `candidate:<sha>` rows, review
artifacts and an EC-3 `worktree_path=` row. On this flow they read `INDETERMINATE`
(plan lock reads `false`), and `q4_records` says once for the mission that no
build is recorded. Built means a Git commit, so work that isn't a commit never
reads built.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
project. The nearest slice, mission or project policy selects authorized judges;
project. The nearest slice, mission or project policy selects authorized judges.
By default an item's latest judgment from any of them decides it; with
`proofPolicy.everyJudge: true`, every listed judge's own current accept is
required, and `rig proof show` lists each judge's own latest judgment either way.
`rig proof --help` describes setup, selectors and evidence requirements. In a
multi-project workspace catalog, name the project with `--project <id>` or a
`<id>:` scope prefix (`alpha:m0/slices/01-t001`); unqualified scopes use the
+10 -3
View File
@@ -153,12 +153,16 @@ drive a recognized native menu as before. Otherwise, with the delivery guard
attached, it reads the visible composer under the seat's lease immediately before
pasting, after buffer preparation. Empty input proceeds. A recognized dim
suggestion is displayed separately and counts as empty; typed or pasted text
(author unknown) stops the send with `override_input_present`. An unreadable input
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
(author unknown) stops the send with `override_input_present`: single-send HTTP
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
not the hidden contents. OpenRig neither holds nor retries this stopped override.
An input that can't be read doesn't stop the override. The answer is typed and
nothing is cleared. The result reports `existingInput` with classification
`unreadable`, never empty, and a warning that the answer may join or submit
existing text.
Inspect the shown input in the native terminal, deliberately clear the prompt with
native/tmux controls, then issue the send again. Manual clearing runs outside
OpenRig's lease and observation checks and is not recorded. For a deliberate
@@ -356,7 +360,10 @@ accounting. Historical outbox receipts do not suppress a lone reminder or preven
message tells its recipient that delivery may repeat after an unconfirmed attempt. Its job history records the
current transport result; an unknown write is not proof that nothing arrived.
The transport still protects approval/question prompts, human drafts and recipient identity. Scheduler timing,
The transport still protects approval/question prompts and recipient identity. Under the default
`transport.draft_delivery` (`deliver`), an ordinary send or work notice that meets a person's draft at a Claude Code or
Codex prompt is pasted into it and the combined input is submitted, unless the seat's typing guard holds input
(`draft-aware`, `openrig-urgent`, `urgent-only` or `hold`). With `refuse`, a draft refuses the send. Scheduler timing,
current parked-row eligibility and stop checks still apply. This changes message retries, not the idempotence of
structured continuity actions such as creating a cutover baton.
+37 -12
View File
@@ -66,7 +66,14 @@ not fill in; if so, follow the form.
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
a possible repeat. An ask OpenRig can't fully check still posts, once per gateway run, through the
person's delivery rules, so a readable quiet window still holds it. That's an ask whose attempt
record can't be read, whose state can't be checked, or whose person's quiet hours can't be read.
The Slack post can show "Delivery may repeat after an earlier unconfirmed attempt.", "OpenRig
couldn't check that this ask is still open." and "OpenRig couldn't read your quiet hours." These
supplementary notes are omitted if they do not fit. The row and command also report when the
person's quiet-hours setting couldn't be read. An ask that is no longer open isn't posted. `--wait <seconds>` (default 10) is how long it waits for
Slack receipts.
## What the app asks for
@@ -377,11 +384,18 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
wake-ladder alerts. These reach the person exactly as they would outside quiet. An ask below the posting level is only
logged, as at any time.
- **When quiet ends,** each held ask that never started posts once, on its own, with no mention. One that no longer applies
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, or it's now below
the posting level.
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, its person has no
connector any more (it stays on Steering and the Feed), or it's now below the posting level.
- **A post that may already be in Slack**, confirmed or only partly sent, isn't held: it finishes as itself, in its own
thread.
- **If quiet hours can't be read,** nothing is posted to that person until they can.
- **If quiet hours can't be read,** that isn't a quiet window. An ask that would post goes to its usual destination with
no mention, and the post says "OpenRig couldn't read your quiet hours."; the ask's row records it. The person's other
delivery settings still decide: an escalation, a digest, an ask below the posting level, and a quiet window that can be
read work as before. This covers ordinary posts and digests, and asks already held for quiet hours: while the quiet
hours can't be read, the next release check (within about a minute) posts each of them, with no mention and the same
line, even if the window may still be on. The line is supplementary: a post at Slack's text or block limit leaves it
out, and the ask's row and `rig slack post-open-asks` still say the quiet hours couldn't be read. A person reached
through a relay waits until their quiet hours can be read, as before, because that post can't carry the line.
## Reconnect recovery and status
@@ -405,9 +419,11 @@ budget window when exhausted. Slack's per-method `Retry-After` is retained acros
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
an archived channel or an uneditable message) remain unfinished. A deleted original is recorded
on the row and never asked about again; a new post of the same ask is tried afresh. The others,
which a scope, membership, a different bot token or an upgrade can fix, are retried on the next
connector start. A message is uneditable when the configured token didn't post it. They do not keep consuming edit capacity. These edit calls have their own budget,
separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
@@ -454,9 +470,18 @@ not covered by that margin. A corrupt or unreadable checkpoint leaves recovery u
and preserves the existing file; live inbound continues.
Each pass admits at most four history calls, 100 entries and 15 seconds of work, with a
five-second request ceiling. Channels and threads take turns within that one budget, so with many
tracked threads each thread is scanned every few passes; `rig slack status` shows how far behind
the oldest waiting thread is. Work already started on a message, such as
five-second request ceiling. Channels and threads take turns within that one budget. Each tracked
thread is scanned once after each start and after each Socket Mode reconnect that followed a gap
(not a refresh Slack overlaps with the old connection), which catches replies sent while no
connection was up; replies while connected arrive live. A thread the pass at the connect itself
reaches is scanned once more, since that pass stops five seconds short of the connect. Those scans go in need order: a thread
leaving the seven-day window within six hours first, since once it leaves a missed reply is never
recovered; then threads whose ask still waits on the person (its queue row is pending, in progress
or blocked) before the rest, each newest first. The order comes from the saved checkpoints, so a
restart starts with the newest open ask, not the oldest thread. After that a thread is scanned again
only as a backstop, hourly while its ask waits on the person and daily otherwise. A machine the hub
relays to can't see a gap in relayed events, so there a settled thread's backstop is three hours. `rig slack status` shows how far behind the oldest waiting thread is and how
many threads are settled. Work already started on a message, such as
an attachment transfer, is not cancelled by these limits or by a stop, and a new
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart, and hold back
only the method Slack limited (channel history or thread replies).
@@ -465,8 +490,8 @@ When Slack reports a plan history limit, recovery still lands the available page
and advances normally, retaining an older-history limitation in status across restarts.
Missing bot credentials, history scope, membership, retention limits and API
failures appear as recovery limitations. A tracked thread Slack can't find stays incomplete in
status, with no further call, until its thread mapping changes or the daemon restarts or rewires
the connector; that isn't proof the thread was deleted. No additional scope is required to keep
status and is asked again at most once a day, across restarts; that isn't proof the thread was
deleted. No additional scope is required to keep
live delivery working. Threads nobody tracks, older open threads, threads closed more than seven
days ago and other channels remain outside this recovery scope; global chronological order is
not promised. This change does not replay intervals already checkpointed before broadcast-reply
+42 -2
View File
@@ -76,12 +76,48 @@ then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
Detaching a tile you typed in returns it to showing the seat. A different seat
under the old name is never followed. If no replacement returns in time, or a
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
the agent's PTY under tmux's normal client sizing policy, and resizing the
terminal reflows new output. Read-only tiles remain observers: they neither
resize nor type into another seat. An ordinary tmux detach ends the writable
tile without stopping the agent. A replacement with a different managed seat
identity is not followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
opened before this change must be closed and reopened to get this behavior.
As in published 0.7.10, Enter or Ctrl-C typed while a new writable tile is still
attaching can close that viewer. The agent keeps running; reopen the view.
Once attached, keys and paste use native tmux input handling.
## Sizing and older views
Use tmux's inherited `window-size latest` policy for the terminal most recently
used to control sizing. OpenRig does not change a person's global tmux policy.
A deliberate `window-size manual` setting remains an explicit fixed-size choice.
OpenRig's temporary layout and readable-capture pins release by unsetting the
window's local `window-size` option; they do not restore an old local override.
With the last pin released, the window inherits the global policy again.
Browser terminals do not pin the agent's PTY by default. For an explicit fixed
90-by-27 browser terminal, use `rig config set terminal.fixed_geometry true`.
Set it to `false` to return to the default. This applies to newly created browser
brokers after existing browser subscribers disconnect; it does not configure
native Herdr tiles. A temporary readable capture uses
`rig capture <seat> --readable` or `--size <columns>x<rows>` and releases its pin
when the capture finishes. It does not unset a manually fixed window it did not
pin.
Kept writable 0.7.8/0.7.9 viewers can retain an old `ignore-size` attach, and
pre-reflow 0.7.11 viewers can retain the old mirrored display. Changing saved
argv cannot change that running client. Close only the owned Herdr viewer pane
and open a fresh view with `rig terminal open <view> --provider herdr`; the
agent's tmux session and conversation keep running. For a retained `--follow`
canvas, first select `--no-follow`, then close its owned viewer panes and open
a fresh ordinary view. Continuing that old following canvas is not an automatic
live-client migration. Existing 0.7.10 sizing clients and read-only viewers do
not need this step.
## Desktop windows and previews
```sh
@@ -153,6 +189,10 @@ timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
adds a note when other pages opened, and names the session to check in Rex
before retrying. `rig terminal status` gives Rex about a second to answer.
## herdr-app
`--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering (finding the socket file is how OpenRig guesses you are in a herdr-app window, and whether the window is reachable is checked by asking it). `--window` always asks for a new desktop window. See [OpenRig views in herdr-app](herdr-app.md).
## HTTP API
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
+10 -2
View File
@@ -12,6 +12,14 @@ export function terminalAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** The credential for operator-gated writes (Mission Control actions, a pairing decision): an explicit bearer, else the
* daemon's operator bearer configured as OPENRIG_AUTH_BEARER_TOKEN, else the terminal credential (the same token on a
* daemon that aliases them; none where none is needed). */
export function operatorAuthHeaders(bearer?: string): Record<string, string> {
const token = bearer?.trim() || process.env.OPENRIG_AUTH_BEARER_TOKEN?.trim();
return token ? { Authorization: `Bearer ${token}` } : terminalAuthHeaders();
}
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
@@ -429,9 +437,9 @@ export class DaemonClient {
// DaemonResponseError carrying the status + a bounded snippet, instead of a
// raw SyntaxError bubbling to a cryptic (json) or silent (human) CLI exit.
// A well-formed non-2xx body still parses and returns {status,data}; 204 has none.
if (res.status === 204) return { status: res.status, data: undefined as T };
if (res.status === 204) return { status: res.status, data: undefined as T, headers: res.headers };
try {
return { status: res.status, data: JSON.parse(text) as T };
return { status: res.status, data: JSON.parse(text) as T, headers: res.headers };
} catch {
throw new DaemonResponseError(res.status, text);
}
+6 -6
View File
@@ -54,9 +54,9 @@ export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
/** What happened to the window after a readable read, as far as OpenRig knows. */
export function readableRelease(release: string | undefined): string {
switch (release) {
case "given-back": return "its window was then given back its size and its own window-size";
case "given-back": return "its size was put back and its window-size override was unset";
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
case "failed": return "giving its window back failed; a later readable capture can retry releasing its pin";
default: return "its window's record had changed, so OpenRig left the window as it found it";
}
}
@@ -122,7 +122,7 @@ function renderCapture(data: Record<string, unknown>): void {
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
if (readable?.resized === true) {
const after = readable.release === "given-back" && readable.sizeBack
? `its own window-size was given back, but ${readable.sizeBack}`
? `its window-size override was unset, but ${readable.sizeBack}`
: readableRelease(readable.release);
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
@@ -190,11 +190,11 @@ Supported notes:
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
window, waits for the seat to redraw, captures, then puts the window back, both its size and
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
window, waits for the seat to redraw, captures, then puts its size back and unsets the temporary
window-size override, so Herdr, tmux attach and other clients size it again. The
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
web terminal has opted into fixed geometry), one whose sizing can't be read, and one with several panes. With --rig
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
are. A stderr note says which; --json has it as readable.
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
+9
View File
@@ -107,6 +107,14 @@ Keys:
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
the telemetry export, the log and the feed, and can't be brought back. Read at the next sweep
(daily and at daemon start)
retention.closed_content_days remove the content of closed queue work (done, canceled, denied, handed-off) N
days after it closed; the row stays, without content. 0 keeps it (default). Can't be undone
retention.closed_metadata_days delete closed queue rows N days after they closed, keeping only their ids so a retry
can't recreate them. 0 keeps them (default). Both read at the next sweep; "rig queue erase
--routine --apply" runs them now
retention.erasure_ledger an absolute file where queue erasure appends each erased id and its action, one JSON
line each, before it commits them; keep it outside backups and replay it with "rig queue
erase --ids <file> --apply" after a restore. Empty keeps no ledger (default)
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
launch.codex_login_shell defer to Codex's own login-shell setting in managed Codex seats (default off)
Off adds allow_login_shell=false: tool commands inherit the seat's environment, so plain rig
@@ -119,6 +127,7 @@ Keys:
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
terminal.fixed_geometry use 90x27 web geometry while subscribed (default off; other viewers share the pinned size)
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).
+37 -5
View File
@@ -42,6 +42,8 @@ import {
import { realDeps } from "./daemon.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { resolveRecapSeat, type RigSeat } from "../recap-seat.js";
import { parseSessionName } from "../session-name.js";
import type { StatusDeps } from "./status.js";
import { reconstructionCommand } from "./context-reconstruction.js";
@@ -424,6 +426,28 @@ Examples:
return deps.clientFactory(getDaemonUrl(status));
}
/** A rig's seats as the daemon records them, for mapping a recap's --seat by identity. Never starts the daemon. */
async function rigSeats(rig: string): Promise<RigSeat[] | { unavailable: string }> {
try {
const client = await getClient();
const rigs = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
if (rigs.status !== 200 || !Array.isArray(rigs.data)) return { unavailable: `the daemon's rig list returned HTTP ${rigs.status}` };
const ids = rigs.data.filter((entry) => entry.name === rig).map((entry) => entry.rigId);
if (ids.length !== 1) return { unavailable: ids.length ? `${ids.length} rigs are named ${rig}` : `the daemon has no rig named ${rig}` };
const nodes = await client.get<Array<{ logicalId: string; podNamespace?: string | null; canonicalSessionName: string | null }>>(
`/api/rigs/${encodeURIComponent(ids[0]!)}/nodes`);
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return { unavailable: `the daemon's seat list returned HTTP ${nodes.status}` };
return nodes.data.flatMap((node) => {
const session = node.canonicalSessionName ? parseSessionName(node.canonicalSessionName) : undefined;
if (session?.kind !== "canonical") return [];
const pod = node.podNamespace;
return [{ logicalId: node.logicalId, member: pod && node.logicalId.startsWith(`${pod}.`) ? node.logicalId.slice(pod.length + 1) : node.logicalId, folder: session.member }];
});
} catch (err) {
return { unavailable: (err as Error).message };
}
}
// OPR.0.5.3.6 — the productized chain-file trace. Daemon-independent by
// design: the walk is a config read + filesystem reads, so it works on a
// box whose daemon is down (orientation is exactly when that happens).
@@ -786,14 +810,21 @@ Examples:
// tree (a sandboxed seat) hands the same recap to the daemon, which owns it.
cmd.command("recap-write")
.description("Write this seat's authored RECAP (decisions-with-rationale) at the handover boundary; supersedes the previous recap into the seat's chain")
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
.requiredOption("--seat <seat>", "Seat id (the seats/<seat> altitude)")
.option("--rig <rig>", "Rig name (the rigs/<rig> altitude; default: this seat's rig, from OPENRIG_SESSION_NAME)")
.option("--seat <seat>", "The seat's folder: the part of its session name before @, e.g. dev-qa (default: this seat). A seat's recorded logical id or member id maps to its folder when the daemon can check it")
.requiredOption("--file <path>", "Markdown file containing the recap content")
.action(async (opts: { rig: string; seat: string; file: string }) => {
.action(async (opts: { rig?: string; seat?: string; file: string }) => {
try {
const { writeSeatRecapAt, validateRecapContract } = await import("@openrig/daemon/seat-recap-store");
const content = readFileSync(opts.file, "utf-8");
const topologyRoot = String(new ConfigStore().resolveWithSource("topology.root").value);
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
const own = sessionName ? parseSessionName(sessionName) : undefined;
const rigName = opts.rig ?? (own?.kind === "canonical" ? own.rig : undefined);
// A named seat is checked against the rig's recorded seats when the daemon answers; a failed check doesn't stop the write.
const seats = opts.seat !== undefined && rigName ? await rigSeats(rigName) : undefined;
const target = resolveRecapSeat({ rig: opts.rig, seat: opts.seat, topologyRoot, sessionName, seats });
if ("error" in target) { console.error(target.error); process.exitCode = 1; return; }
for (const f of validateRecapContract(content)) {
console.error(f.kind === "no-decisions-section"
? "ADVISORY no-decisions-section: the authoring contract asks for decisions WITH rationale — conclusions alone are the lossy handoff shape."
@@ -801,7 +832,7 @@ Examples:
}
let written: { path: string; superseded: number; daemonUrl?: string };
try {
written = writeSeatRecapAt({ topologyRoot, rig: opts.rig, seat: opts.seat, content });
written = writeSeatRecapAt({ topologyRoot, rig: target.rig, seat: target.seat, content });
} catch (err) {
if (!WRITE_REFUSED_CODES.has((err as NodeJS.ErrnoException).code ?? "")) throw err;
// The daemon owns the topology tree (it primes RECAP.md at the next rebuild): it writes the same recap through
@@ -809,12 +840,13 @@ Examples:
// that can't be reached is reported with its reason.
console.error(`Note: this process can't write the topology tree (${(err as Error).message}), so the daemon writes the recap instead.`);
const client = await getClient();
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: opts.rig, seat: opts.seat, content });
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: target.rig, seat: target.seat, content });
if (res.status !== 200 || typeof res.data.path !== "string") throw new Error(res.data.message ?? `Daemon returned HTTP ${res.status}`);
written = { path: res.data.path, superseded: res.data.superseded ?? 0, daemonUrl: client.baseUrl };
}
console.log(`Recap written: ${written.path}${written.superseded > 0 ? ` (${written.superseded} superseded predecessor${written.superseded === 1 ? "" : "s"} retained)` : ""}`
+ (written.daemonUrl ? `, by the daemon at ${written.daemonUrl}` : ""));
for (const note of target.notes) console.error(`Note: ${note}`);
} catch (err) {
// A daemon write that timed out says its outcome is unknown, so it's checked before a retry supersedes it again.
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
+14 -3
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import fs from "node:fs";
import { createServer } from "node:net";
import { randomUUID } from "node:crypto";
import { acquireDaemonStartLock } from "../daemon-start-lock.js";
import { acquireDaemonStartLock, bootedAtMs, currentBootId } from "../daemon-start-lock.js";
import { formatDaemonHostForUrl } from "../client.js";
import { execFileSync, spawn } from "node:child_process";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
@@ -94,7 +94,11 @@ export function realDeps(): LifecycleDeps {
return {
processLiveness,
acquireStartLock: () => acquireDaemonStartLock(OPENRIG_DIR),
acquireStartLock: () => {
const lock = acquireDaemonStartLock(OPENRIG_DIR, { bootId: currentBootId(), bootedAtMs: bootedAtMs(), processLiveness });
if (lock.archived) console.error(`A start reservation from before this boot was left behind; archived it as ${lock.archived}`);
return lock;
},
spawn: (cmd, args, opts) => spawn(cmd, args, opts as Parameters<typeof spawn>[2]),
fetch: async (url) => {
const res = await fetchWithTimeout(globalThis.fetch, url, {}, {
@@ -165,7 +169,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
cmd
.command("start")
.description("Start the daemon")
.addHelpText("after", "\nStartup reserves this local instance before initialization and verifies the spawned child PID on every required listener.\nMissing/mismatched identity or child exit fails startup; failed publication withdraws only this launch's matching state. Use a matching CLI/daemon installation.\nA concurrent start fails without spawning another child. Inspect daemon-start.lock for launcher/child PIDs after an interrupted start;\nonly archive an abandoned reservation after proving both processes absent. A failed cleanup retains it and reports the child PID.\n")
.addHelpText("after", "\nStartup reserves this local instance before initialization and verifies the spawned child PID on every required listener.\nMissing/mismatched identity or child exit fails startup; failed publication withdraws only this launch's matching state. Use a matching CLI/daemon installation.\nA concurrent start fails without spawning another child. Inspect daemon-start.lock for launcher/child PIDs after an interrupted start;\nonly archive an abandoned reservation after proving both processes absent. A failed cleanup retains it and reports the child PID.\nA reservation from an earlier boot is archived as daemon-start.lock.abandoned-<time> and the start goes on.\n")
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Host to bind on")
.option("--db <path>", "Database path")
@@ -267,13 +271,19 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const { attentionLines, checkKernelAttention } = await import("../seat-attention.js");
const { DaemonClient } = await import("../client.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
const baseUrl = `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
const result = await waitForKernelReady(baseUrl, timeoutMs);
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
const heldLines = result.kernelState !== "ready" && result.kernelState !== "skipped"
? attentionLines(await checkKernelAttention(new DaemonClient(baseUrl)), "pane").map(line => ` ${line}`) : [];
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
for (const line of heldLines) console.log(line);
} else if (result.kernelState === "skipped") {
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
console.error(skippedKernelMessage(result.detail));
@@ -287,6 +297,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
"\n" +
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
);
for (const line of heldLines) console.error(line);
process.exitCode = 1;
}
}
+21 -4
View File
@@ -281,7 +281,7 @@ Examples:
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
const { listHumans, NO_HUMAN_CONFIGURED } = await import("@openrig/daemon/gateway-human-registry");
const res = listHumans();
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
const humans = await Promise.all(res.humans.map(async (record) => {
@@ -290,7 +290,7 @@ Examples:
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
}));
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); 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; }
if (res.humans.length === 0) { console.log(NO_HUMAN_CONFIGURED); return; }
for (const h of humans) {
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";
@@ -567,9 +567,14 @@ Examples:
human
.command("set <entityId> <field> <value>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n> (a full kind:connectorRef:secretsRef:role[:handle=<id>] spec, or unset to remove that binding); and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
.option("--actor <actor>", "Named operator when outside a managed seat")
.addHelpText("after", `
Removing a connector binding (binding.<n> unset):
- The one-primary rule stays: no replacement primary is chosen silently. If removing a primary binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (kind:connectorRef:secretsRef:role[:handle=<id>]) from rig gateway human show <entityId> --json, then unset that secondary slot, then find the primary's current slot in remainingBindings (or rig gateway human show <entityId> --json, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role primary (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from remainingBindings or rig gateway human show <entityId>, then finish at binding.0 (once all secondaries are gone, the primary is at binding.0).
- Remaining bindings are printed with their shifted binding.<n> slot indexes (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift after removal, do not blindly retry 'binding.0 unset'; check remainingBindings, the receipt, or 'rig gateway human show <entityId>' before retrying.
- Removing a binding keeps the person, their preferences, their other bindings and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.`)
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (field === "organization" || field === "groups" || field === "policy") {
@@ -602,7 +607,19 @@ Examples:
return { value: res, after: { digest }, effect: digest === prior ? "no-op" : "applied" };
},
});
console.log(JSON.stringify({ ok: true, entityId, field, path: result.value.path, receipt: result.receipt }));
console.log(JSON.stringify({
ok: true,
entityId,
field,
path: result.value.path,
receipt: result.receipt,
...(value === "unset"
? {
remainingBindings: result.value.remainingBindings ?? [],
note: result.value.note,
}
: {}),
}));
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
return;
}
+50 -3
View File
@@ -29,7 +29,7 @@ import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } 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";
import { DaemonClient, operatorAuthHeaders } from "../client.js";
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
import { parseFeedHostSubscriptionKey } from "../config-store.js";
@@ -862,7 +862,8 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
// retry loop forever. Values that work today keep their wait. The target
// keeps the request open as long as this side waits, and its approval says so.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string;
person?: string; owner?: string | null; ownerNote?: string; noReader?: string };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
@@ -884,7 +885,15 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
if (!opts.json) {
console.log(`Pairing code: ${issued.code}`);
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
// A target that says who was asked and who reads the answer: name them. An older target keeps the general text.
if (issued.person) {
console.log(`The request went to ${issued.person} on the target.`);
console.log(issued.owner
? `${issued.owner} reads their answer and decides${issued.ownerNote ? ` (${issued.ownerNote}, so ${issued.owner} reads it instead)` : ""}.`
// The target's own words: none reads answers, or it couldn't tell. Never a categorical "nobody" over an unknown.
: `On the target: ${issued.noReader ?? "it didn't say whether an agent reads answers there"}. The request names how to decide directly on the target.`);
} else console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
}
// A note names the bound actually used so a clamped value is not silently absorbed.
@@ -958,6 +967,44 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
printHostNextSteps(added.entry);
});
// The explicit decision on a pairing request made to THIS host: run by the agent that reads the person's answer, or by
// a person at a terminal here. One decision per request; the daemon records who made it.
for (const decision of ["approve", "deny"] as const) {
cmd
.command(decision)
.description(decision === "approve"
? "Approve a pairing request made to this host: the requesting machine receives this host's token"
: "Deny a pairing request made to this host: nothing is handed over")
.argument("<request>", "The pairing code, or the request's approval row id")
.option("--bearer <token>", "This daemon's operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN); pairing targets always have one")
.option("--json", "JSON output")
.action(async (request: string, opts: { bearer?: string; json?: boolean }) => {
type Decided = { ok?: boolean; error?: string; message?: string; code?: string; requester?: string; by?: string };
// The decision is gated by the operator bearer (OPENRIG_AUTH_BEARER_TOKEN), as rig queue resolve and rig mode are.
let res: { status: number; data: Decided };
try {
res = await new DaemonClient().post<Decided>("/api/hosts/pair-decision", { request: request.trim(), decision },
{ headers: operatorAuthHeaders(opts.bearer) });
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
return;
}
if (opts.json) console.log(JSON.stringify(res.data));
if (res.status !== 200 || !res.data?.ok) {
if (!opts.json) console.error(res.status === 401
? `This daemon refused the decision without its operator bearer token. Set OPENRIG_AUTH_BEARER_TOKEN to the token this daemon runs with, or pass --bearer <token>. Nothing was decided.`
: res.data?.message ?? res.data?.error ?? `the pairing decision failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
if (opts.json) return;
console.log(decision === "approve"
? `Approved pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. The requesting machine receives this host's token when it next checks.`
: `Denied pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. Nothing was handed over.`);
});
}
cmd
.command("list")
// OPR.0.4.6.MH1 QA fixback: the PRD/proof contract spells the verb
+1 -1
View File
@@ -35,7 +35,7 @@ type LaunchResponse = {
// operator must act). These are NOT successful launches — never print "Launched" for
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
function launchStatusRunning(status: string): boolean {
export function launchStatusRunning(status: string): boolean {
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
}
+18 -5
View File
@@ -143,6 +143,11 @@ export function proofCommand(): Command {
The selected daemon owns show/judge. Scope addresses are mission/slices/slice#item.
The project owner selects proofPolicy: { judges: [exact-seat-address] } in the
owning slice.yaml, mission.yaml or project.yaml (nearest wins; no default gate roles).
By default an item's latest judgment from any listed judge decides it. Add
everyJudge: true to require every listed judge's own current accept: one judge's
accept then can't hide another's reject, withdraw, missing judgment or stale
evidence. Turning it on is a policy change, so earlier judgments must be recorded
again. rig proof show lists each judge's own latest judgment either way.
The contract is the authored ## Proof contract. A sole item needs no # selector.
Example (existing evidence, no copied hashes or operation key):
@@ -163,9 +168,17 @@ checkboxes do not accept an item under the selected proof policy.
if (cmd.opts().workspace) throw new Error("--workspace applies to proof add. Select the judgment daemon using OPENRIG_URL; proof show reports its scope basis.");
return new DaemonClient();
};
const response = async (r: { status: number; data: unknown }): Promise<Record<string, any>> => {
// A path_escape response names the supported routes to another workspace's tree.
const projectSelected = (project: unknown, scope: unknown) => Boolean(project) || /^[^/:]+:/.test(typeof scope === "string" ? scope : "");
const escapeHint = (project: boolean) => project
? "\nThe scope is outside that project's missions root; name a mission/slice path inside it."
: "\nThe scope is outside the selected daemon workspace. For a catalog project, pass --project <id> or prefix the scope with <project>:."
+ " To use another daemon's workspace, select that daemon with OPENRIG_URL.";
const response = async (r: { status: number; data: unknown }, project = false): Promise<Record<string, any>> => {
const data = r.data as Record<string, any>;
if (r.status >= 400) throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}`);
if (r.status >= 400) {
throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}${data.error === "path_escape" ? escapeHint(project) : ""}`);
}
return data;
};
const projectHelp = "Workspace-catalog project id; resolves the scope under that project's missions root (same as a <project>:<scope> prefix)";
@@ -175,7 +188,7 @@ checkboxes do not accept an item under the selected proof policy.
.action(async (scope, opts) => {
try {
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), ...(scope ? { scope } : {}) }).toString();
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`));
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`), projectSelected(opts.project, scope));
console.log(opts.json ? JSON.stringify(data) : JSON.stringify(data, null, 2));
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
});
@@ -196,7 +209,7 @@ checkboxes do not accept an item under the selected proof policy.
const refs = [...new Set<string>([...opts.evidence, ...(opts.comparison ? [opts.comparison] : [])])];
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), scope });
for (const ref of refs) query.append("evidence", ref);
const c = client(), view = await response(await c.get(`/api/proof?${query}`));
const c = client(), view = await response(await c.get(`/api/proof?${query}`), projectSelected(opts.project, scope));
const items = view.items as Array<{ id: string; text: string; index: number; revision: string; judgment: { id: string } | null }> | undefined;
const item = selector ? items?.find(i => i.id === selector || i.text === selector || String(i.index) === selector) : items?.length === 1 ? items[0] : undefined;
if (!item) throw new Error("Select one current item with scope#item; rig proof show lists IDs, text and indices");
@@ -209,7 +222,7 @@ checkboxes do not accept an item under the selected proof policy.
operationId: opts.operationId, replace: opts.replace === true, ...(opts.project ? { project: opts.project } : {}),
// Pin the project root the prepared read resolved; the daemon refuses (409 project_changed) if it moved.
...((view.project as { root?: string } | undefined)?.root ? { projectRoot: (view.project as { root: string }).root } : {}) };
const result = await response(await c.post("/api/proof/judge", body));
const result = await response(await c.post("/api/proof/judge", body), projectSelected(opts.project, scope));
console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
});
+1 -1
View File
@@ -605,7 +605,7 @@ and native credential-store requirements are unchanged. No fresh account home or
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
a launch_wrapper; changing a seat between Pi and another runtime is not supported.
a launch_wrapper. To change runtime, use rig seat set-runtime with --runtime pi, codex or claude-code.
--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
+26 -2
View File
@@ -43,7 +43,7 @@ interface PsEntry {
isArchived?: boolean;
}
interface NodeEntry {
export interface NodeEntry {
/** Served daemon attention, never re-derived here. Missing on an older daemon. */
needsAttention?: boolean;
arbitratedNeedsInput?: boolean | null;
@@ -716,6 +716,26 @@ function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
/** Why a seat that needs attention does, from what the daemon recorded: a missing session, a startup error, a held
* message, a missing required file, or the served needs-input reason. Otherwise "reason unavailable". */
export function attentionReason(n: NodeEntry): string {
return missingSessionGuidance(n) ?? n.latestError ?? (n.heldReason ? `held: ${n.heldReason}` : null)
?? (n.startedWithoutRequired?.length ? startedWithoutLine(n.startedWithoutRequired) : null)
?? questionReason(n) ?? "reason unavailable";
}
/** Whether the daemon serves a live question for the seat, with its own precedence: the arbitrated needs-input answer,
* and the hook only when there's none. A served "no" beats a stale hook. */
export function servesQuestion(n: NodeEntry): boolean {
return n.arbitratedNeedsInput ?? (n.agentActivity?.state === "needs_input");
}
/** The served question's reason, with the daemon's precedence: the arbitrated answer, and the hook only without one. */
export function questionReason(n: NodeEntry): string | null {
return n.arbitratedNeedsInput === true ? n.activityState?.needsInput.reason ?? null
: n.arbitratedNeedsInput == null && n.agentActivity?.state === "needs_input" ? n.agentActivity.reason : null;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -1370,8 +1390,12 @@ async function handleNodes(
if (useCompact) {
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
// With the daemon's attention flag set, a live question shows its own reason rather than a recorded error. Without
// that flag (an older daemon), the order stays as it was, so a startup failure isn't hidden by a hook. The question's
// reason is shown whole, as the activity reason always was: it's what a person reads to know what to answer.
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
? (n.needsAttention === true && servesQuestion(n) ? questionReason(n) ?? "reason unavailable"
: n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
+212 -9
View File
@@ -4,7 +4,7 @@ import { Command, InvalidArgumentError } from "commander";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, remoteDaemonClient, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
import { sessionRigOf, isHumanSeatSessionRef, parseSessionName } from "../session-name.js";
import { realDeps } from "./daemon.js";
import { enumArg, positiveIntArg } from "../cli-error.js";
import type { StatusDeps } from "./status.js";
@@ -544,7 +544,11 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
let remote = remoteHostOf(hostId);
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
const requestedRemote = remote;
const selfHostId = requestedRemote ? await daemonSelfHostId(client) : undefined;
if (remote && selfHostId === remote) remote = undefined;
const remoteConfirmed = requestedRemote !== undefined && typeof selfHostId === "string"
&& selfHostId.trim().length > 0 && selfHostId !== requestedRemote;
let reader = client;
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
if (remote) {
@@ -559,7 +563,7 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
reader = deps.clientFactory(match.host.url);
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
}
return { reader, options };
return { reader, options, remote, remoteConfirmed };
}
/** Where a verb that names an existing row (show, transitions, list, claim, update, block) sends its request with
@@ -623,7 +627,7 @@ function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: stri
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string; remoteConfirmed?: boolean }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
const target = await queueReadTarget(deps, client, hostId);
@@ -638,7 +642,8 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
if (differs) {
return { row: null, note: `Row ${qitemId}${where} exists but isn't this create (its ${differs} differs), so the outcome is still unknown. Don't reuse this ID for other work.` };
}
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).` };
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).`,
remoteConfirmed: target.remoteConfirmed };
}
if (res.status === 404) return { row: null, note: `No row ${qitemId}${where} yet (read back once by its ID); it may still commit.` };
return { row: null, note: `Reading row ${qitemId}${where} back answered ${res.status}, so the outcome is still unknown.` };
@@ -890,6 +895,54 @@ function requireSeatSession(ignoredFlag: string): string | undefined {
return undefined;
}
/** The flags `rig queue update` takes that write more than a note. `--source` records a note only (#2866). */
const UPDATE_STATE_WRITE_FLAGS = [
["state", "--state"], ["reopen", "--reopen"], ["closureReason", "--closure-reason"], ["closureTarget", "--closure-target"],
["closureRequiredAt", "--closure-required-at"], ["resolution", "--resolution"], ["blockedOn", "--blocked-on"],
["wakeWatchdog", "--wake-watchdog"], ["wakeAfter", "--wake-after"], ["summary", "--summary"], ["evidenceRef", "--evidence-ref"],
] as const;
/**
* #2866: who appends a `rig queue update` note. Inside a managed seat it is the seat (the X-OpenRig-Session header the
* DaemonClient stamps), whatever --source says. Outside one, a routine can name itself with --source <name>@external:
* it rides the body as the declared actor, which the daemon records as claimed:v1 because no header names a seat, so
* the note is attributed to an external actor and never to a seat, and OPENRIG_SESSION_NAME is never forged. The
* external path records a note only, on the local daemon (the caller refuses a --host that resolves to another host).
* Returns the body actor to send (undefined = the header decides), or false after printing why nothing was sent.
*/
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
if (source !== undefined) {
const trimmed = source.trim();
if (parseSessionName(trimmed).kind !== "external") {
return refuse(`--source must be <name>@external (got '${source}'): it names a routine outside any seat, never a seat. Nothing was sent.`);
}
const writes = UPDATE_STATE_WRITE_FLAGS.filter(([key]) => opts[key] !== undefined).map(([, flag]) => flag);
if (opts.note === undefined || writes.length > 0) {
return refuse(`--source records a note only: pass --note and nothing else that writes${writes.length > 0 ? ` (got ${writes.join(", ")})` : ""}. ` +
"Changing a row needs a seat. Nothing was sent.");
}
if (seat) {
process.stderr.write(`warning: --source ${trimmed} is ignored: this runs in the seat ${seat}, which is the note's actor.\n`);
return {};
}
return { actorSession: trimmed };
}
if (seat) return {};
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
return refuse(noteOnly
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
"say) does it in the TUI (rig tui, then attention) or in Steering, not with this command.");
}
function extractRigName(sessionName: string): string | undefined {
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
return sessionRigOf(sessionName);
@@ -1182,8 +1235,51 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
const sent: SentCreate = { destinationSession: hostResolved.destination, claimedSource: managedSource === undefined ? source : undefined, body: resolvedBody,
remoteHost: remoteHostOf(hostResolved.hostId) };
const emitScopeAdvisories = (created: Record<string, unknown>) => {
const advisories = Array.isArray(created?.advisories) ? created.advisories : [];
for (const value of advisories) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const advisory = value as { code?: unknown; message?: unknown };
if (typeof advisory.message !== "string") continue;
switch (advisory.code) {
case "scope_check_skipped":
console.error(`Note: ${advisory.message}`);
break;
case "unresolved_scope":
case "ambiguous_scope":
case "empty_scope":
case "conflicting_scope":
case "scope_check_unavailable":
console.error(`Warning: ${advisory.message}`);
break;
}
}
};
const emitRecoveredScopeAdvisory = (recoveredRemote: boolean) => {
const recoveredTags: string[] = [];
for (const raw of tags ?? []) {
if (recoveredTags.length >= 8) break;
if (!raw.startsWith("mission:") && !raw.startsWith("slice:")) continue;
const prefix = raw.startsWith("mission:") ? "mission:" : "slice:";
const body = raw.slice(prefix.length).replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 120);
const sanitized = body ? `${prefix}${body}` : prefix;
if (!recoveredTags.includes(sanitized)) recoveredTags.push(sanitized);
}
if (recoveredTags.length === 0) return;
// Only a confirmed different host ID justifies "forwarded";
// all other recovered outcomes remain neutral.
if (recoveredRemote) {
console.error(`Note: queue create scope check skipped for cross-host write (${recoveredTags.join(", ")}); row was forwarded.`);
} else {
console.error(`Warning: queue create scope check outcome unavailable after response recovery (${recoveredTags.join(", ")}): original scope check result was lost with the create response; row was created.`);
}
};
// Every create that's answered with a row ends here: its warning, --verify's delivery, and the output.
const finish = async (created: Record<string, unknown>, status: number) => {
const finish = async (created: Record<string, unknown>, status: number, recovered?: "neutral" | "remote") => {
if (status < 400) {
if (recovered) emitRecoveredScopeAdvisory(recovered === "remote");
else emitScopeAdvisories(created);
}
const createWarning = created?.createWarning as { code?: unknown; message?: unknown } | undefined;
if (status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
console.error(`Warning: ${createWarning.message}`);
@@ -1213,7 +1309,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
console.error(late.note);
if (late.row) {
await finish(late.row, 200);
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
return;
}
}
@@ -1230,7 +1326,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
console.error(late.note);
if (late.row) {
await finish(late.row, 200);
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
return;
}
// Preserve that failure and expose the same recovery ID.
@@ -1299,11 +1395,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
actor?: string;
source?: string;
state?: string;
reopen?: boolean;
closureReason?: string;
@@ -1338,11 +1436,21 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
// header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal). Matches `resolve`.
if (!requireSeatSession("actor")) return;
// #2866: outside a seat, --source <name>@external names the routine appending a note (the declared body actor).
const actor = resolveUpdateActor(opts.source, opts);
if (!actor) return;
const deps = getDeps();
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, true);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
if (actor.actorSession !== undefined && target.remote) {
// #2866: the other host would record the routine bare, indistinguishable from a routine of its own (a
// forwarded create stamps the origin; this path doesn't). A --host that resolves locally is fine.
console.error(`--source with --host ${target.remote} is not supported: that host would record ${actor.actorSession} ` +
"without the host it ran on. Run the routine on the host that holds the row. Nothing was sent.");
process.exitCode = 1;
return;
}
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state: opts.state,
reopen: opts.reopen,
@@ -1356,6 +1464,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
...actor,
}, target.options);
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
@@ -1999,6 +2108,100 @@ Examples:
});
});
cmd
.command("erase")
.description("Erase queue rows in one exact scope: an account's rows, a list of ids, or the closed-work retention windows; a dry run unless --apply")
.option("--members <file>", "Account deletion: the account's recorded session names (exact, departed seats included), one per line or a JSON array")
.option("--rig <id>", "With --members: the account rig's id. Also erases the rig's own records that outlive it (events, snapshots, node and seat records); refused unless every member is a session of it")
.option("--ids <file>", "These qitem ids, one per line or a JSON array; a report's qitems entries, or an erasure ledger's JSON lines, keep each one's action")
.option("--action <action>", "With --ids: delete (default) removes each row and keeps only its id; scrub removes its content and keeps the row", enumArg(["delete", "scrub"]))
.option("--routine", "Run this daemon's closed-work windows now (retention.closed_content_days, retention.closed_metadata_days)")
.option("--apply", "Erase. Without it the command reports what would be erased and changes nothing")
.option("--actor <session>", "Who is erasing, when OPENRIG_SESSION_NAME isn't set (recorded as a claimed identity)")
.option("--json", "JSON output for agents")
.addHelpText("after", [
"",
"Name exactly one scope. What each one erases:",
" --members every row whose source and destination are both members, in any state, deleted with its transitions,",
" outbox and inbox copies, stream actions, events and audit rows; then member-to-member outbox and inbox",
" entries no row owns, and the members' stream items. A row with one member endpoint is reported under",
" mixed and left untouched. With --rig, also the rig's own events, snapshots and node and seat records.",
" --ids the listed rows, deleted or scrubbed (a report's qitems entries keep their action); a deleted id",
" that's already gone keeps (or regains) its id marker.",
" --routine closed rows (done, canceled, denied, handed-off) past the windows, aged from when they closed.",
"A deleted row keeps only its id, and a create that reuses that id is refused (qitem_erased). A row a workflow still",
"references is reported under gaps and left. Output is JSON: clean (true only when nothing was left), complete,",
"qitems, mixed, gaps, alreadyAbsent, linked, unlinked and rig. Refusals: erase_scope_required, erase_scope_conflict,",
"erase_members_invalid, erase_rig_invalid, erase_rig_mismatch, erase_ids_invalid, erase_action_invalid,",
"erase_routine_off. A run that stops on an error returns what it committed, with error.stage, and exits 2.",
"Reference: docs/reference/queue-erasure.md",
].join("\n"))
.action(async (opts: { members?: string; rig?: string; ids?: string; action?: string; routine?: boolean; apply?: boolean; actor?: string; json?: boolean }) => {
const list = (flag: string, path: string): unknown[] | undefined => {
let text: string;
try {
text = fs.readFileSync(path, "utf8");
} catch (err) {
console.error(`--${flag} ${path}: can't be read (${err instanceof Error ? err.message : String(err)}). Nothing was erased.`);
process.exitCode = 1;
return undefined;
}
if (text.trimStart().startsWith("[")) {
try {
const parsed: unknown = JSON.parse(text);
if (Array.isArray(parsed)) return parsed;
} catch { /* reported below */ }
console.error(`--${flag} ${path}: not a JSON array. Nothing was erased.`);
process.exitCode = 1;
return undefined;
}
// One entry per line: a plain id or session, or (an erasure ledger's line) a JSON object.
const entries: unknown[] = [];
for (const line of text.split(/\r?\n/).map((entry) => entry.trim()).filter((entry) => entry.length > 0 && !entry.startsWith("#"))) {
if (!line.startsWith("{")) {
entries.push(line);
continue;
}
try {
entries.push(JSON.parse(line));
} catch {
console.error(`--${flag} ${path}: a line isn't valid JSON (${line.slice(0, 60)}). Nothing was erased.`);
process.exitCode = 1;
return undefined;
}
}
return entries;
};
const body: Record<string, unknown> = { apply: opts.apply === true };
if (opts.members !== undefined) {
const members = list("members", opts.members);
if (!members) return;
body.members = members;
}
// Sent as given: the daemon refuses a rig without members (erase_rig_invalid), never drops it.
if (opts.rig !== undefined) body.rig = opts.rig;
if (opts.ids !== undefined) {
const ids = list("ids", opts.ids);
if (!ids) return;
body.ids = ids;
if (opts.action) body.action = opts.action;
}
if (opts.routine) body.routine = true;
if (!readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")) {
if (!opts.actor) {
console.error("OPENRIG_SESSION_NAME is not set and --actor wasn't given, so there's no one to record as erasing. Nothing was erased.");
process.exitCode = 1;
return;
}
body.actorSession = opts.actor;
}
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/erase", body);
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Inbox subcommands ----
cmd
+15
View File
@@ -2186,6 +2186,17 @@ function sharedSliceNameFinding(missionsRoot: string, missionName: string, slice
};
}
/** Show the inherited workspace route because Commander lists a parent option only in the parent's help. */
export function scopeWorkspaceHelp(tier: "slice" | "mission", verb?: string): string {
return `
Workspace: the parent option --workspace selects the missions root this command reads.
rig scope --workspace <project root> ${tier} ${verb ?? "<verb>"} ...
A project root uses its project.yaml missions.root, else its missions/ folder; a
folder named missions is used as is. Use it for a catalog project, for example
$(rig config get workspace.projects_root)/<project>. Without --workspace,
OPENRIG_WORK_ROOT applies, then the configured workspace.slices_root.`;
}
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
@@ -2216,6 +2227,10 @@ export function scopeCommand(): Command {
mission.addCommand(buildMissionVerifiedCommand());
mission.addCommand(buildApproveCommand("mission"));
cmd.addCommand(mission);
for (const [tier, group] of [["slice", slice], ["mission", mission]] as const) {
group.addHelpText("after", scopeWorkspaceHelp(tier));
for (const verb of group.commands) verb.addHelpText("after", scopeWorkspaceHelp(tier, verb.name()));
}
cmd.addCommand(buildResolveNotesCommand());
cmd.addCommand(buildAuditCommand());
+69 -11
View File
@@ -21,6 +21,8 @@ async function defaultReadStdin(): Promise<string> {
}
interface SeatStatusResponse {
/** What OpenRig's own refocus hook has recorded for this seat (Claude and Codex seats). */
refocus?: { lastRecordedAt: string | null; currentConversation: boolean | "unknown"; noIdentity?: true; detail: string };
permission_stop?: { state: "unavailable"; command: "unavailable"; origin: "unknown" }
| { state: "request_observed" | "completed"; command: string; origin: "harness";
source: "PermissionRequest"; observed_at: string; actor: "main" | "subagent"; pending: "unknown" | false;
@@ -76,6 +78,9 @@ interface SeatStatusResponse {
ignored?: { source: string; value: string };
};
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
/** A running Claude or Codex seat: when OpenRig wrote each block in its guidance file, before or after a recorded session
* moment (its startup finish, or when a claimed session was recorded). Chronology only, never what the agent read. */
guidance?: { file: string; detail: string; blocks: Array<{ id: string; writtenAt: string | null; written: "before" | "after" | "unknown"; reason?: string }> };
session_reason?: string | null;
startup_status: string | null;
occupant_lifecycle: string;
@@ -140,6 +145,7 @@ interface SeatHandoverMutationResult {
ok: true;
dryRun: false;
mutated: true;
/** Legacy compatibility field, always false. Read currentStatus.continuityOutcome and sourceOutcome instead. */
continuityTransferred: false;
seat: SeatHandoverPlan["seat"];
source: {
@@ -168,7 +174,8 @@ interface SeatHandoverMutationResult {
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string };
model?: { requested: string | null; predecessorCompared: false } };
/** The restore or rebuild priming packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
/** #2456: the required startup files the successor started without, where a start would have failed. */
@@ -243,6 +250,7 @@ function printHuman(status: SeatStatusResponse): void {
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.guidance) console.log(`Guidance: ${status.guidance.detail}`);
if (status.typingGuard) {
const g = status.typingGuard;
const mode = g.effectiveLevel ?? g.effectiveMode ?? (g.effective ? "on" : "off");
@@ -273,6 +281,7 @@ function printHuman(status: SeatStatusResponse): void {
if (rules?.size_cut) console.log(" Suggested-rule evidence is incomplete because of size bounds.");
}
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
if (status.refocus) console.log(`Refocus: ${status.refocus.detail}`);
const reason = sessionReason(status.session_reason);
if (reason) console.log(`Reason: ${reason}`);
if (status.permissions?.account) {
@@ -384,7 +393,11 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
if (result.sourceOutcome.forkId === "pending") console.log(`Fork id: PENDING. ${result.sourceOutcome.limit ?? ""}`.trim());
} else if (result.sourceOutcome?.mode === "rebuild") {
const outcome = result.sourceOutcome;
console.log("Rebuild starts a new conversation; durable artifacts carry its context, not a native resume.");
if (outcome.permissions) console.log(`Codex rebuild permissions: ${outcome.permissions.state}. ${outcome.permissions.detail}`);
if (outcome.model) {
console.log(`Codex rebuild model: ${outcome.model.requested ? `requested ${outcome.model.requested}` : "runtime configuration/default"}. The predecessor's in-pane model was not compared or carried.`);
}
if (outcome.primedArtifacts.length > 0) {
const heading = result.restorePacket?.status === "unconfirmed" ? "Rebuild packet includes" : "Rebuild primed from";
console.log(`${heading} ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
@@ -973,6 +986,47 @@ Examples:
});
});
cmd
.command("decide-permission")
.argument("<request>", "The permission request's id (qitem-runner-permission-…), as the row handed to you names it")
.requiredOption("--option <id>", "One of the options the agent offered, by its id")
.option("--reason <text>", "Why, recorded with the decision (say what the person meant)")
.option("--json", "JSON output for agents")
.description("Decide a runner seat's permission request that its person answered: one of the agent's offered options")
.addHelpText("after", `
When a person answers a runner seat's permission request, with a click or in their own
words, the request goes to this host's receiving agent to decide while the runner waits.
This records that agent's decision: one of the options the agent offered, which the runner
then gives its agent. It's recorded as an agent's decision, not the person's, with the
deciding seat and the reason. A request that's already decided, withdrawn or stale is
reported as it is, and nothing is written. See docs/reference/harness-runner-contract.md,
section 7.
Example:
rig seat decide-permission qitem-runner-permission-0123abcd --option allow --reason "they said go ahead"`)
.action(async (request: string, opts: { option: string; reason?: string; json?: boolean }) => {
type Decided = { ok?: boolean; state?: string; optionId?: string; code?: string; error?: string };
let res: { status: number; data: Decided };
try {
res = await new DaemonClient().post<Decided>(`/api/activity/permission-requests/${encodeURIComponent(request.trim())}/decide`,
{ optionId: opts.option.trim(), reason: opts.reason ?? "" });
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
return;
}
if (opts.json) console.log(JSON.stringify(res.data));
if (res.status !== 200 || !res.data?.ok) {
if (!opts.json) console.error(res.data?.error ?? `the decision failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
if (opts.json) return;
const state = res.data.state;
console.log(state === "answered" ? `Decided: ${res.data.optionId}. The runner gives it to its agent at its next read.`
: state === "refused" ? "Decided: refused. The runner gives its agent the refusal at its next read."
: `Nothing was decided: the request is ${state}.`);
});
cmd
.command("effort")
.argument("<seat>", "Canonical session name or logical seat ref")
@@ -1035,13 +1089,13 @@ resume/successor launch reads it at call time. Examples:
cmd
.command("set-runtime")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--runtime <runtime>", "codex or claude-code")
.option("--runtime <runtime>", "codex, claude-code or pi")
.option("--reason <text>", "Why the seat moves; recorded with the switch")
.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("--account-home <path>", "Target Codex/Claude home or Pi account directory containing auth.json; also permits a same-runtime successor")
.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")
@@ -1051,7 +1105,7 @@ resume/successor launch reads it at call time. Examples:
.option("--note <text>", "With --confirm: what you'll do first")
.option("--recover", "Finish an interrupted switch from its record")
.option("--json", "JSON output for agents")
.description("Move a live Codex or Claude Code seat to the other runtime, successor first")
.description("Move a live Codex, Claude Code or Pi seat to another runtime, successor first")
.addHelpText("after", `
The seat keeps its address, connections, queue rows and claims. Its successor
starts on the target runtime beside the current occupant, reads a handover, and
@@ -1065,7 +1119,7 @@ later converge reports a deferred change_runtime); the source runtime's model,
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.
Codex, Claude Code and Pi, 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.
@@ -1437,12 +1491,16 @@ Examples:
.option("--json", "JSON output for agents")
.description("Stop exactly one LIVE managed seat (kills only that seat's tmux session; audited)")
.addHelpText("after", `
Siblings are untouched. A dead seat is refused (use rig seat clean); an adopted
session is refused (use rig unclaim). A seat handed over (fresh, fork or rebuild)
before OpenRig 0.7.9 still reads adopted and is refused too, until its next launched
handover or managed launch; the refusal says so. 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
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
session is refused: to end only it, run rig unclaim <session> (it releases the
session and leaves its process running), then end that process in its own
terminal. rig down <rig> ends every seat in its rig, so it is the route only
for a one-seat rig.
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
adopted and is refused too, until its next launched handover or managed launch;
the refusal says so. 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.
+18 -7
View File
@@ -61,6 +61,8 @@ export interface EnvelopeMeta {
/** Twin of pane-envelope's field: the row a person's (@external) message delivers, named in the
* reply hint with --reply-to so the answer joins its Slack thread. ABSENT ⇒ the plain hint. */
replyToQitem?: string;
/** Twin of pane-envelope's field (#2895 (a)): false ⇒ the reply line names no `rig send`. ABSENT or true ⇒ today's hint. */
senderReachable?: boolean;
}
/** The To-line projection + anti-storm scale (header-alone distinguishability). */
@@ -108,7 +110,9 @@ export function wrapSendBody(
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: meta?.senderReachable === false
? `↩ No reply by rig send: ${senderLabel} has no session.`
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -246,7 +250,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added for the complete visibly staged answer, or away from a recognized prompt when the input box can't be found or read.")
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
.option("--urgent", "Mark this message urgent: it types at seats whose interruption level holds other messages. Anyone may mark one; the recipient sees who did")
@@ -322,8 +326,10 @@ would run the envelope lines; the output says so. Use
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
option or approve a permission) — the only override of the prompt guard; it
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
be menu shortcuts, and CR/ESC act as keys. An Enter is added only when the complete
answer is visibly staged in a recognized text input. Combining --verify with
be menu shortcuts, and CR/ESC act as keys. An Enter is added when the complete
answer is visibly staged in a recognized text input, and, away from a recognized
prompt or menu, when the input box can't be found or read (the result says so).
Combining --verify with
--dangerously-interact never adds an automatic submit retry, even when the daemon
did not detect a prompt or did not report its disposition. Unknown consumption
is not a send failure. On a managed seat with the delivery guard attached, a
@@ -332,7 +338,10 @@ pasted text stops the send and is shown only to you, separately from recognized
dim suggestions. Collapsed paste contents and unrecognized inputs are not known.
Inspect the native terminal, deliberately clear its prompt with native/tmux
controls, then retry. Manual clearing runs outside OpenRig's lease and observation
checks and is not recorded. --type-without-checking explicitly skips this read;
checks and is not recorded. An input that can't be read doesn't stop the send:
the answer is typed, nothing is cleared, the result says the input couldn't be
read, and the answer may join or submit what is there.
--type-without-checking explicitly skips this read;
it does not clear anything and may join or submit existing text. Human typing
can still race a capture and paste; the check is not atomic.
@@ -1134,9 +1143,11 @@ async function runFanOutSend(params: {
}
const data = res.data;
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown; warning?: string }>) ?? [];
// Context beside a recipient's own result: its own advisory (such as unreadable input), and whether
// it was in a managed compaction or restore when the send ran.
const printNotice = (r: (typeof results)[number]) => {
if (r.warning) console.log(`${r.sessionName}: Advisory: ${r.warning}`);
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
};
// Round-2 F1 (desk-binding): ONE verdict line per recipient. A
+1 -1
View File
@@ -193,7 +193,7 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
scope: "project",
runtime: "claude-code",
path: "CLAUDE.md (or CLAUDE.local.md when the rig chooses it)",
purpose: "OpenRig managed blocks at each start: the rig's culture, the OpenRig Start identity pointer, the onboarding pages at a fresh start (onboarding.default_pack.enabled), and guidance files a team merges. The router line is added only on a yes. rig down removes the blocks unless a seat of another active rig, or a still-running seat of this one, uses the file.",
purpose: "OpenRig managed blocks at each start: the rig's culture, the OpenRig Start identity pointer, the context-entry pointer to skills, help and declared context, the onboarding pages at a fresh start (onboarding.default_pack.enabled), and guidance files a team merges. The router line is added only on a yes. rig down removes the blocks unless a seat of another active rig, or a still-running seat of this one, uses the file.",
},
{
scope: "project",
+7
View File
@@ -56,6 +56,9 @@ interface SeatInspection {
bootstrapLine?: { state: "unasked" | "unknown" } | { state: "yes" | "no"; by: "saved" | "environment" } | null;
/** OPR.0.7.0.9: what the seat's launch passes of its own additions; absent from an older daemon. */
launchAdditions?: SeatLaunchAdditionsView | null;
/** #2895 (b): set for a seat on a runtime without seat skills; absent from an older daemon. */
unsupported?: true;
note?: string;
}
interface SeatApplyResponse {
@@ -185,6 +188,10 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
process.exitCode = 1;
return;
}
if (!opts.apply && data.unsupported) {
if (!opts.json) console.log(data.note);
return;
}
const inspection = opts.apply ? data.inspection : data;
if (!opts.json) printSeat(inspection);
if (!opts.apply) {
+7 -4
View File
@@ -122,7 +122,7 @@ const CHANNEL_MAP_APPLY =
const POST_OPEN_ASKS = "`rig slack post-open-asks`";
type OpenAskOutcome = "posted" | "already-posted" | "queued" | "held" | "below-floor" | "no-longer-active" | "superseded"
| "recipient-changed" | "state-unavailable" | "transport-failed" | "uncertain";
| "recipient-changed" | "no-connector" | "state-unavailable" | "transport-failed" | "uncertain";
interface OpenAsk { qitemId: string; recipient: string; summary: string | null; outcome: OpenAskOutcome; detail?: string }
interface PostOpenAsksResponse { ok: boolean; asks: OpenAsk[] }
@@ -135,6 +135,7 @@ const OUTCOME_LABEL: Record<OpenAskOutcome, string> = {
"no-longer-active": "no longer active",
superseded: "superseded",
"recipient-changed": "recipient changed",
"no-connector": "no connector (Steering and the Feed only)",
"state-unavailable": "state unavailable",
"transport-failed": "transport failed",
uncertain: "uncertain",
@@ -180,7 +181,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.command("setup")
.description("Configure the connector (first-class config; secrets stay in the env file, never here)")
.option("--channel <id>", "Slack channel id the connector app must be a member of")
.option("--inbound-destination <session>", "where inbound human messages land (default operator-agent@kernel)")
.option("--inbound-destination <session>", "where a person's unrouted message lands: a seat's session, or default-receiver (the default) for the host's default receiving agent (agents.operator_session, else the kernel operator)")
.option("--minimum-level-that-posts <level>", "minimum OWNER level posted to Slack: RECORD|NOTICE|ALERT")
.option("--minimum-level-that-interrupts <level>", "minimum OWNER level that mentions/interrupts: RECORD|NOTICE|ALERT")
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
@@ -250,7 +251,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
for (const r of readiness) log(` ${r.ok ? "✓" : "✗"} ${r.label}: ${r.detail}`);
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
inboundRouting?: { fallback: string; destination: string; destinationFrom?: string; destinationNote?: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
@@ -258,7 +259,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
coverage?: { coverageStart: string; coveredThrough: string; pending?: { upper: string; nextLatest: string }; nextRetryAt?: number } | null;
channels?: Array<{ channel?: string | null; state?: string; reason?: string; lastScanAt?: string;
coverage?: { coverageStart: string; coveredThrough: string; nextRetryAt?: number } | null }>;
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; oldestPendingSeconds?: number | null;
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; settled?: number; settledRescanHours?: number; oldestPendingSeconds?: number | null;
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
@@ -267,6 +268,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
if (connector.inboundRouting?.destinationNote) log(` ${connector.inboundRouting.destinationFrom === "receiving-agent" ? "Default receiving agent" : "No receiving agent"}: ${connector.inboundRouting.destinationNote}`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
const recovery = connector.recovery;
@@ -288,6 +290,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
const behind = threads.oldestPendingSeconds != null ? ` (oldest ${Math.round(threads.oldestPendingSeconds / 60)} min behind)` : "";
log(` Thread recovery: ${threads.state ?? "unknown"}${threads.reason ? ` (${threads.reason})` : ""}; ${threads.tracked ?? 0} tracked (open, or closed within seven days); `
+ `${threads.scanned ?? 0} scanned, ${threads.pending ?? 0} waiting their turn${behind}`
+ `${threads.settled ? `, ${threads.settled} settled (nobody waiting; rescanned ${!threads.settledRescanHours || threads.settledRescanHours === 24 ? "daily" : `every ${threads.settledRescanHours} hours`})` : ""}`
+ `${reasons(threads.backoff) ? `; backing off: ${reasons(threads.backoff)}` : ""}${reasons(threads.incomplete) ? `; incomplete: ${reasons(threads.incomplete)}` : ""}`);
}
log(` Recovery counts since connector start: accepted ${recovery?.acceptedThisProcess ?? "unknown"}; dead-lettered ${recovery?.deadLetteredThisProcess ?? "unknown"} (custody, not delivery)`);
+10 -1
View File
@@ -11,6 +11,7 @@ import {
import { readLifecycleDescription } from "../daemon-lifecycle-status.js";
import { realDeps } from "./daemon.js";
import { ConfigStore } from "../config-store.js";
import { attentionLines, checkKernelAttention } from "../seat-attention.js";
export interface StatusDeps {
lifecycleDeps: LifecycleDeps;
@@ -136,8 +137,12 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
} catch {
// config resolution unavailable - null rather than a guess.
}
// A kernel short of ready: its seats that need attention, and any it couldn't check (never read as none).
const attention = kernelRes?.status === 200 && kernelRes.data?.kernel_state && kernelRes.data.kernel_state !== "ready"
? await checkKernelAttention(client) : null;
const kernel = kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null }
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null,
...(attention && (attention.attention.length || attention.unknown.length) ? { attention } : {}) }
: { state: kernelRes && kernelRes.status === 503 ? "not_tracked" : "unknown", lastBootFailure: null };
const daemon = { state: "running", reachable: true, pid: status.pid, port: status.port, healthy: true };
if (summaryRes.status !== 200) {
@@ -163,6 +168,10 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
if (failure) {
console.log(` Recovered from an earlier boot failure (${failure.state}${failure.at ? ` at ${failure.at}` : ""}): ${failure.detail ?? "no detail recorded"}`);
}
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
if (kernelRes.data.kernel_state !== "ready") {
for (const line of attentionLines(await checkKernelAttention(client), "pane")) console.log(` ${line}`);
}
} else if (kernelRes && kernelRes.status === 503) {
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
} else {
+118 -9
View File
@@ -24,10 +24,40 @@ 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";
import { attentionLines, checkSeatAttention } from "../seat-attention.js";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
/** herdr-app's home, resolved the way herdr-app resolves it (as the TUI does). */
function herdrAppHome(env: NodeJS.ProcessEnv): string {
return env["HERDR_APP_HOME"] || join(env["XDG_CONFIG_HOME"] || join(env["HOME"] || homedir(), ".config"), "herdr-app");
}
export interface TerminalDeps extends StatusDeps {
windowDeps?: WindowDeps;
attachTmux?: (target: TmuxAttachment) => Promise<number>;
/** This process's environment (herdr-app context); injectable for tests. */
env?: NodeJS.ProcessEnv;
socketExists?: (path: string) => boolean;
}
/**
* The herdr-app client a command addresses, or null when it doesn't use herdr-app: `--provider herdr-app` opens the
* view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the
* same when that window is answering (its socket file is a hint; the window is asked before it is chosen). `--window`
* always asks for a new desktop window.
*/
export function herdrAppClient(opts: { provider?: string; window?: boolean; appHome?: string }, deps: TerminalDeps):
{ appHome: string; ambient: boolean } | null {
if (opts.window) return null;
const env = deps.env ?? process.env;
const named = opts.provider?.trim();
const exists = deps.socketExists ?? existsSync;
const ambient = !named
&& !!env["HC_TERMINAL_ID"] && !!env["HC_TERMINAL_SOCKET"] && exists(join(herdrAppHome(env), "herdr-app.sock"));
if (named !== "herdr-app" && !ambient) return null;
return { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env), ambient };
}
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
@@ -44,6 +74,8 @@ export interface OpenViewResult {
session?: string;
ok: boolean;
opened: string[];
/** Opened seats on another host, with that host: complete when sent, so its absence (an older daemon) means unknown. */
remote?: Array<{ seat: string; host: string }>;
absent: { seat: string; host: string | null; reason: string }[];
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
pages: number;
@@ -106,6 +138,23 @@ function humanOpen(r: OpenViewResult): string {
return lines.join("\n");
}
/** Names the opened seats that need attention (a startup prompt, a sign-in, a failed start), so a held seat doesn't
* read as an open that failed for no reason. Local seats are checked on this daemon; a seat on another host, or one
* whose host the result doesn't say, is reported unchecked, never matched to a local seat of the same name. */
async function withHeldSeats(client: DaemonClient, r: OpenViewResult): Promise<OpenViewResult> {
const seats = [...new Set([...r.opened, ...(r.following?.tiles.map(t => t.seat) ?? [])])];
if (!Array.isArray(r.remote)) {
// An older daemon names opened seats without saying which are remote: none can be checked locally by name.
const lines = seats.length ? attentionLines({ attention: [], unknown: seats.map(seat => ({ seat, why: "the daemon didn't say which host each tile is on" })) }, "tile") : [];
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
}
const remote = new Map(r.remote.map(({ seat, host }) => [seat, host]));
const check = await checkSeatAttention(client, seats.filter(seat => !remote.has(seat)));
for (const [seat, host] of remote) check.unknown.push({ seat, why: `it's on host ${host}, which this daemon doesn't check` });
const lines = attentionLines(check, "tile");
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
}
/** A successful existing-workspace selection also needs no new panes. */
function printOpen(json: boolean, r: OpenViewResult, status: number): void {
if (json) {
@@ -121,7 +170,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
export function terminalCommand(depsOverride?: TerminalDeps): Command {
const cmd = new Command("terminal").description(
"Open the OpenRig view and agent conversations in terminal tiles",
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside a herdr-app terminal, omit --window to open in that window.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
const getDeps = (): TerminalDeps =>
depsOverride ?? {
@@ -133,9 +182,10 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.command("open")
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
.option("--provider <name>", "herdr, herdr-app, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one), and herdr-app opens a Rig view in the herdr-app window this runs in, which must be answering; tmux requires --window. Inside Rex, the default opens in Rex")
.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("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted outside herdr-app); inside a herdr-app terminal --window still asks for a desktop window, and plain open reuses herdr-app")
.option("--app-home <dir>", "herdr-app: the client to open in (default: herdr-app's own home, from HERDR_APP_HOME or ~/.config/herdr-app)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux/macOS Herdr protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
@@ -148,7 +198,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string; appHome?: string;
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
const deps = getDeps();
@@ -157,12 +207,51 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
const herdrApp = herdrAppClient(opts, deps);
await withClient(deps, async (client) => {
// An ambient herdr-app terminal does not claim --follow; only an explicit --provider herdr-app errors with follow_unsupported.
let selectedHerdrApp = herdrApp?.ambient && following ? null : herdrApp;
let ambientFallbackNote: string | undefined;
if (selectedHerdrApp?.ambient) {
const probe = await client.get<{
available?: boolean;
launch?: { socketPath?: string };
providers?: Array<{ status?: { available?: boolean; launch?: { socketPath?: string } } }>;
}>(
`/api/terminal/status?provider=herdr-app&appHome=${encodeURIComponent(selectedHerdrApp.appHome)}`,
);
const report = probe.data?.providers?.[0]?.status ?? probe.data;
if (probe.status >= 400 || typeof report?.available !== "boolean") {
const socketPath = report?.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but the daemon could not confirm whether it is answering (HTTP ${probe.status}); opening a desktop window instead. Use --provider herdr-app to require it.`;
console.error(ambientFallbackNote);
selectedHerdrApp = null;
} else if (!report.available) {
const socketPath = report.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but it isn't answering; opening a desktop window instead. Use --provider herdr-app to require it.`;
console.error(ambientFallbackNote);
selectedHerdrApp = null;
}
}
if (selectedHerdrApp) {
// herdr-app: a fresh Rig view in that client's own window, through the daemon. No desktop window.
if (following) { printResult(opts.json ?? false, { ok: false, provider: "herdr-app", code: "follow_unsupported", error: "Following isn't available in herdr-app yet; open the view without --follow." }, 400); return; }
const { appHome } = selectedHerdrApp;
const res = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr-app",
herdrApp: { appHome },
...(opts.expectedPlan !== undefined ? { expectedPlan: opts.expectedPlan } : {}) }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
if (!Array.isArray(res.data?.opened)) { printResult(opts.json ?? false, res.data, res.status); return; }
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
return;
}
// Herdr's control socket can answer with no desktop client attached.
// The default requests a desktop; an explicit provider reuses its workspace.
// --window never selects herdr-app
if (following?.enabled !== false && (opts.window || !opts.provider)) {
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
printOpen(opts.json ?? false, result, 200);
const windowProvider = opts.provider?.trim() === "herdr-app" ? undefined : opts.provider;
const result = await openTerminalWindow(client, view, windowProvider, deps.windowDeps, opts.expectedPlan, session, following);
const out = ambientFallbackNote ? { ...result, notes: [...(result.notes ?? []), ambientFallbackNote] } : result;
printOpen(opts.json ?? false, await withHeldSeats(client, out), 200);
return;
}
let expectedPlan = opts.expectedPlan;
@@ -187,7 +276,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
printResult(opts.json ?? false, res.data, res.status);
return;
}
printOpen(opts.json ?? false, res.data, res.status);
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
});
});
@@ -203,18 +292,38 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
});
});
cmd
.command("close <view>")
.description("Close a herdr-app Rig view (rv-…): its cells leave the layout, then only its own viewers end; seats keep running")
.option("--app-home <dir>", "herdr-app home the view is in (default: herdr-app's own)")
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { appHome?: string; json?: boolean }) => {
const deps = getDeps();
const env = deps.env ?? process.env;
await withClient(deps, async (client) => {
const res = await client.post<OpenViewResult>("/api/terminal/close", { view, provider: "herdr-app",
herdrApp: { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env) } }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
if (opts.json) { printResult(true, res.data, res.status); return; }
console.log(res.data?.ok ? `Closed Rig view ${view}.` : `Not closed: ${res.data?.error ?? `HTTP ${res.status}`}`);
for (const note of res.data?.notes ?? []) console.log(` ${note}`);
if (res.status >= 400) process.exitCode = 1;
});
});
cmd
.command("status")
.description("Show terminal provider availability + liveness (doctor)")
.option("--provider <name>", "restrict to one provider (herdr / cmux / rex)")
.option("--provider <name>", "restrict to one provider (herdr / herdr-app / cmux / rex)")
.option("--session <name>", "Inspect a named Herdr session on the daemon's host")
.option("--app-home <dir>", "herdr-app: the client to inspect (default: herdr-app's own home)")
.option("--json", "JSON output for agents")
.action(async (opts: { provider?: string; session?: string; json?: boolean }) => {
.action(async (opts: { provider?: string; session?: string; appHome?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
let path = opts.provider
? `/api/terminal/status?provider=${encodeURIComponent(opts.provider)}`
: "/api/terminal/status";
if (opts.provider === "herdr-app") path += `&appHome=${encodeURIComponent(opts.appHome ? resolve(opts.appHome) : herdrAppHome(deps.env ?? process.env))}`;
const session = opts.session?.trim();
if (session !== undefined) path += `${opts.provider ? "&" : "?"}session=${encodeURIComponent(session)}`;
const res = await client.get<{ session?: string; error?: string }>(path);
+23 -1
View File
@@ -11,6 +11,7 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonS
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
import { launchStatusRunning } from "./launch.js";
import type { StartOptions } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -120,6 +121,17 @@ snapshot, resuming seats where their harness allows; it refuses while any of its
whose rig name already exists starts a new team in place of the stopped one, which
is archived, and refuses if the old one may still be running. To bring a stopped
team back with its conversations, run rig up <rig-name> --existing, not the spec.
After a restore, rig up exits 0 only when the rig came back whole (rigResult
fully_restored). It exits 1 when it didn't (partially_restored, failed or
not_attempted), including when every seat is running but some started fresh
without their conversation, or came back with a guidance or restore gap. With
--json, read rigResult and each node's status, noConversation, guidanceGaps and
restoreGaps to tell these apart: a seat with a gap keeps its own status, such as
resumed, and a fresh seat marked noConversation (a terminal seat) lost nothing.
A refused request exits 1 for a conflict (HTTP 409, such as a rig still running
or a restore not attempted) and 2 for any other refusal (400, 404 or 500, such
as no rig by that name or a failed boot).
`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -706,7 +718,7 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
}
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
if (rigResult) console.log(`Result: ${rigResult}`);
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string; noConversation?: true; guidanceGaps?: string[]; restoreGaps?: string[] }>) ?? [];
for (const n of nodes) {
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
// awaiting-decision reason is part of the line (not a dead end), and so is a failed seat's (such as a seat held
@@ -717,6 +729,16 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
console.log(` ${n.logicalId}: ${n.status}`);
}
}
// Every seat running still exits 1 when the restore isn't whole; say why, so nobody retries a start that worked.
if (rigResult === "partially_restored" && nodes.length > 0 && nodes.every((n) => launchStatusRunning(n.status))) {
const fresh = nodes.filter((n) => (n.status === "fresh" || n.status === "fresh-primed") && !n.noConversation).length;
const gapped = nodes.filter((n) => (n.guidanceGaps?.length ?? 0) > 0 || (n.restoreGaps?.length ?? 0) > 0).map((n) => n.logicalId);
const reasons = [
...(fresh > 0 ? [`${fresh} started fresh without their conversation`] : []),
...(gapped.length > 0 ? [`${gapped.join(", ")} came back with a gap named in a warning`] : []),
];
console.log(`${nodes.length === 1 ? "The one seat is" : `All ${nodes.length} seats are`} running${reasons.length > 0 ? `; ${reasons.join("; ")}` : ""}. The restore isn't whole, so rig up exits 1.`);
}
const warnings = (res.data["warnings"] as string[]) ?? [];
for (const w of warnings) {
console.error(` warning: ${w}`);
+1 -1
View File
@@ -72,7 +72,7 @@ export function describeDaemonRejection(body: unknown, instanceHint?: string): T
return {
fact: "The failed step no longer exists in the cached workflow spec.",
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
action: "Restore the step in the spec (then re-register it through the workflows-folder scan or an instantiate by path to refresh the cache) or instantiate a fresh run.",
};
case "instance_version_conflict":
return {
+1 -1
View File
@@ -172,7 +172,7 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
cmd
.command("validate <specPath>")
.description("Validate a workflow spec file (returns structured ok/error report)")
.description("Validate a workflow spec file in memory, without registering it (returns structured ok/error report)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
+45
View File
@@ -170,6 +170,7 @@ export interface RiggedConfig {
tmuxServer: "inherit" | "isolated";
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
historyLimit: number;
fixedGeometry: boolean;
inheritTmuxEnvironment: boolean;
};
// Slice 15 (OPR.0.7.0.15) — the ONE switch for compact command-output
@@ -221,6 +222,9 @@ export interface RiggedConfig {
streamDays: number;
streamMaxItems: number;
eventsDays: number;
closedContentDays: number;
closedMetadataDays: number;
erasureLedger: string;
batchSize: number;
};
// S04 (OPR.0.5.5.4) — pickup-receipt stall threshold (twin of the daemon settings-store).
@@ -370,6 +374,7 @@ const DEFAULTS = {
statusBar: false,
tmuxServer: "inherit",
historyLimit: 10000,
fixedGeometry: false,
inheritTmuxEnvironment: false,
},
// Slice 15 — compact command-output defaults OFF until the switch is on.
@@ -415,6 +420,9 @@ const DEFAULTS = {
streamDays: 30,
streamMaxItems: 20000,
eventsDays: 0,
closedContentDays: 0,
closedMetadataDays: 0,
erasureLedger: "",
batchSize: 500,
},
// S04 — pickup-receipt stall threshold default (twin of daemon getDefaultValue).
@@ -533,6 +541,7 @@ export const VALID_KEYS = [
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 — compact command-output defaults; lockstep with the daemon twin.
"output.compact",
@@ -565,6 +574,12 @@ export const VALID_KEYS = [
"retention.stream_max_items",
// The history-only event types of `events` older than N days, 0 (default) keeps them; lockstep with the daemon twin.
"retention.events_days",
// Closed queue work: content erased N days after it closed, then the row (only its id kept); 0 (default) keeps them;
// lockstep with the daemon twin.
"retention.closed_content_days",
"retention.closed_metadata_days",
// Queue erasure's ledger file (JSON lines of erased ids and actions); empty keeps none; lockstep with the daemon twin.
"retention.erasure_ledger",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
@@ -664,6 +679,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
@@ -687,6 +703,9 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
"retention.events_days": { primary: "OPENRIG_RETENTION_EVENTS_DAYS" },
"retention.closed_content_days": { primary: "OPENRIG_RETENTION_CLOSED_CONTENT_DAYS" },
"retention.closed_metadata_days": { primary: "OPENRIG_RETENTION_CLOSED_METADATA_DAYS" },
"retention.erasure_ledger": { primary: "OPENRIG_RETENTION_ERASURE_LEDGER" },
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
@@ -770,6 +789,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
"output.whoami_peers": ["output", "whoamiPeers"],
@@ -792,6 +812,9 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"retention.events_days": ["retention", "eventsDays"],
"retention.closed_content_days": ["retention", "closedContentDays"],
"retention.closed_metadata_days": ["retention", "closedMetadataDays"],
"retention.erasure_ledger": ["retention", "erasureLedger"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
@@ -1178,6 +1201,24 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.events_days: must be an integer >= 0 (0 keeps the events), got "${raw}"`);
}
},
// 0 (the default) keeps closed work; a positive day count turns its erasure on (queue-erasure).
"retention.closed_content_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.closed_content_days: must be an integer >= 0 (0 keeps the content), got "${raw}"`);
}
},
"retention.closed_metadata_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.closed_metadata_days: must be an integer >= 0 (0 keeps the rows), got "${raw}"`);
}
},
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
"retention.erasure_ledger": (raw) => {
const value = (raw ?? "").trim();
if (value !== "" && !value.startsWith("/")) {
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1415,6 +1456,7 @@ export class ConfigStore {
statusBar: v("terminal.status_bar") as boolean,
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
historyLimit: v("terminal.history_limit") as number,
fixedGeometry: v("terminal.fixed_geometry") as boolean,
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
},
output: {
@@ -1449,6 +1491,9 @@ export class ConfigStore {
streamDays: v("retention.stream_days") as number,
streamMaxItems: v("retention.stream_max_items") as number,
eventsDays: v("retention.events_days") as number,
closedContentDays: v("retention.closed_content_days") as number,
closedMetadataDays: v("retention.closed_metadata_days") as number,
erasureLedger: v("retention.erasure_ledger") as string,
batchSize: v("retention.batch_size") as number,
},
queue: {
+124 -4
View File
@@ -1,23 +1,53 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export interface DaemonStartLock {
recordChild(pid: number): void;
release(preserve?: boolean): void;
/** Where a reservation left from before this boot was archived, when this launch did so. */
archived?: string;
}
export interface StaleLockCheck {
/** This boot's identity (`currentBootId`), recorded in the reservation; undefined where the OS has none. */
bootId?: string;
/** When this machine booted, in ms since the epoch. */
bootedAtMs: number;
/** Three-state liveness (`createProcessLiveness`): only "dead" counts as gone. */
processLiveness: (pid: number) => "alive" | "dead" | "unknown";
/** Test seam: runs after the reservation was inspected and before it is claimed. */
afterInspect?: () => void;
}
/** One supported local launch owns pre-bind initialization through publication.
* ponytail: no age-based takeover — a dead launcher can leave a live unbound child.
* Hard-crash recovery inspects the recorded PIDs before archiving this file. */
export function acquireDaemonStartLock(home: string): DaemonStartLock {
* The one exception is a reservation from an earlier boot: a reboot ends every process it could name.
* A reservation that records its boot ID is from an earlier boot when that ID differs from this one's;
* PIDs don't matter then, since after a reboot they may belong to unrelated processes.
* One without a boot ID (written by an older CLI) must have been written and last changed before
* this boot, with every recorded PID positively dead; the PID check covers a clock that jumped after
* boot, and a PID that can't be inspected keeps the refusal.
* Otherwise hard-crash recovery inspects the recorded PIDs before archiving this file. */
export function acquireDaemonStartLock(home: string, stale?: StaleLockCheck): DaemonStartLock {
fs.mkdirSync(home, { recursive: true });
const file = path.join(home, "daemon-start.lock");
let fd: number;
let archived: string | undefined;
try {
fd = fs.openSync(file, "wx", 0o600);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
throw new Error(`Daemon startup is already reserved: ${file}. Inspect its launcher/child PIDs, daemon.json and daemon.log. If the launch was abandoned, prove both processes are absent before archiving the reservation and retrying; no child spawned.`);
archived = stale ? archiveIfBeforeBoot(file, stale) : undefined;
if (!archived) throw reserved(file);
try {
fd = fs.openSync(file, "wx", 0o600);
} catch (retry) {
// Another launch took the path after this one archived the old reservation: it owns startup.
if ((retry as NodeJS.ErrnoException).code === "EEXIST") throw reserved(file);
throw retry;
}
}
const identity = fs.fstatSync(fd);
const release = (preserve = false): void => {
@@ -33,7 +63,8 @@ export function acquireDaemonStartLock(home: string): DaemonStartLock {
}
};
try {
fs.writeSync(fd, JSON.stringify({ launcherPid: process.pid, startedAt: new Date().toISOString() }) + "\n");
const bootId = stale?.bootId ? { bootId: stale.bootId } : {};
fs.writeSync(fd, JSON.stringify({ launcherPid: process.pid, startedAt: new Date().toISOString(), ...bootId }) + "\n");
} catch (error) {
release();
throw error;
@@ -41,5 +72,94 @@ export function acquireDaemonStartLock(home: string): DaemonStartLock {
return {
recordChild: (pid) => { fs.writeSync(fd, JSON.stringify({ childPid: pid }) + "\n"); },
release,
...(archived ? { archived } : {}),
};
}
function reserved(file: string): Error {
return new Error(`Daemon startup is already reserved: ${file}. Inspect its launcher/child PIDs, daemon.json and daemon.log. If the launch was abandoned, prove both processes are absent before archiving the reservation and retrying; no child spawned.`);
}
/** This machine's boot time, in ms since the epoch. */
export function bootedAtMs(): number {
return Date.now() - os.uptime() * 1000;
}
/** An ID that changes on every boot: Linux's boot_id, macOS's kern.bootsessionuuid; else undefined. */
export function currentBootId(): string | undefined {
try {
const id = process.platform === "linux"
? fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8")
: process.platform === "darwin"
? execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 2_000 })
: "";
return id.trim() || undefined;
} catch {
return undefined;
}
}
/** Archives a reservation from an earlier boot (see `acquireDaemonStartLock`) and returns the archive's path.
* Anything unreadable or from this boot stays where it is, and so does a legacy one naming a PID that isn't
* positively dead. One launch wins: the archive is a hard link named after the inspected file's inode, made
* with link(2), which never replaces an existing name. A launch that lost the race, or whose path now holds a
* different file, gets EEXIST or a mismatched inode and keeps the refusal. Only the winner removes the old
* name, and nothing else can replace it while it exists, so the winner never removes another launch's lock.
* A winner that dies between link and unlink leaves both names; later starts refuse, as before this change. */
function archiveIfBeforeBoot(file: string, stale: StaleLockCheck): string | undefined {
let text: string;
let inspected: fs.Stats;
let fd: number;
try {
fd = fs.openSync(file, "r");
} catch {
return undefined;
}
try {
inspected = fs.fstatSync(fd);
text = fs.readFileSync(fd, "utf8");
} catch {
return undefined;
} finally {
fs.closeSync(fd);
}
const pids: number[] = [];
let startedAtMs = Number.NaN;
let lockBootId: string | undefined;
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let record: { launcherPid?: unknown; childPid?: unknown; startedAt?: unknown; bootId?: unknown };
try {
record = JSON.parse(line);
} catch {
return undefined;
}
for (const pid of [record.launcherPid, record.childPid]) {
if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) pids.push(pid);
}
if (typeof record.startedAt === "string") startedAtMs = Date.parse(record.startedAt);
if (typeof record.bootId === "string" && record.bootId) lockBootId = record.bootId;
}
if (!Number.isFinite(startedAtMs) || pids.length === 0) return undefined;
if (lockBootId && stale.bootId) {
if (lockBootId === stale.bootId) return undefined;
} else {
if (Math.max(startedAtMs, inspected.mtimeMs) >= stale.bootedAtMs) return undefined;
if (!pids.every((pid) => stale.processLiveness(pid) === "dead")) return undefined;
}
stale.afterInspect?.();
const stamp = new Date(startedAtMs).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
const target = `${file}.abandoned-${stamp}-${inspected.ino}`;
try {
fs.linkSync(file, target);
} catch {
return undefined;
}
const linked = fs.statSync(target);
if (linked.dev !== inspected.dev || linked.ino !== inspected.ino) {
fs.unlinkSync(target);
return undefined;
}
fs.unlinkSync(file);
return target;
}
@@ -46,6 +46,7 @@ export function parseLogicalCheckboxes(block: string | null): LogicalCheckboxIte
if (!block) return [];
const lines = block.split(/\r?\n/);
const out: LogicalCheckboxItem[] = [];
let fence: { marker: string; length: number } | null = null;
let current: { checked: boolean; parts: string[]; indent: number; sourceLine: number } | null = null;
const flush = () => {
@@ -56,6 +57,18 @@ export function parseLogicalCheckboxes(block: string | null): LogicalCheckboxIte
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// Fenced examples do not declare proof items or shift evidence indexes.
const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (fence) {
if (marker && marker[1]![0] === fence.marker && marker[1]!.length >= fence.length
&& /^[ \t]*$/.test(marker[2]!)) fence = null;
continue;
}
if (marker && (marker[1]![0] !== "`" || !marker[2]!.includes("`"))) {
flush();
fence = { marker: marker[1]![0]!, length: marker[1]!.length };
continue;
}
const m = line.match(CHECKBOX_LINE);
if (m) {
flush();
+35 -2
View File
@@ -410,7 +410,8 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
throw new ScopeCliError({
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
consequence: "Command did not run.",
action: "List available missions with: rig scope mission ls",
action: "List available missions with: rig scope mission ls. "
+ "For a mission in another workspace, such as a catalog project, select its tree with the parent option: rig scope --workspace <project root> mission <verb> <mission>",
});
}
@@ -528,10 +529,15 @@ export function findSlice(
// Walk up to find the owning mission.
const owningMissionPath = findOwningMission(missionsRoot, candidate);
if (!owningMissionPath) {
const workspace = workspaceHoldingSlice(missionsRoot, candidate);
const error = new ScopeCliError({
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
consequence: "Cannot determine mission context for this slice.",
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
action: workspace
? `The slice is in another workspace's missions tree (${workspace.missionsRoot}), not under ${missionsRoot}. `
+ `Select that tree with the parent --workspace option, for example: rig scope --workspace ${quoteArg(workspace.option)} slice <verb> ${quoteArg(slicePath)}`
: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/. "
+ "For a slice in another workspace, such as a catalog project, select its tree with the parent option: rig scope --workspace <project root> slice <verb> <slice-path>",
});
if (!hintedName) throw error;
firstContextError ??= error;
@@ -551,6 +557,33 @@ export function findSlice(
});
}
/** POSIX single-quoting for a path shown in a command. */
function quoteArg(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* For a slice directory the selected missions root does not hold, the --workspace value that
* selects the nearest missions tree that does: the project root when that resolves to the tree,
* else the missions folder itself. Each value is checked with resolveMissionsRoot, so the named
* option is one that works. Null when no ancestor is a readable missions tree.
*/
function workspaceHoldingSlice(selectedRoot: string, sliceDir: string): { option: string; missionsRoot: string } | null {
const selected = path.resolve(selectedRoot);
const target = path.resolve(sliceDir);
for (let root = path.dirname(target); root !== path.dirname(root); root = path.dirname(root)) {
if (root === selected || !walkToMission(root, target)) continue;
for (const option of [path.dirname(root), root]) {
try {
if (path.resolve(resolveMissionsRoot({ override: option, strictOverride: true })) === root) return { option, missionsRoot: root };
} catch {
// Not a workspace for this tree; try the next form.
}
}
}
return null;
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
// path.dirname never ends in a separator, so compare against the normalized root
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
+48 -7
View File
@@ -305,6 +305,18 @@ function projectForExternal<T>(row: T, view: ExternalView): T {
return fields as T;
}
/** A handed-on item as a scoped caller receives it: without the daemon's own successor fields (successors,
* successorsTruncated, successorNote), which name rows and remote ids regardless of this caller's scope. Its answer is
* show's scope-checked successor (successorOf). */
function withoutDaemonSuccessors<T>(row: T): T {
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
const fields = { ...(row as Record<string, unknown>) };
delete fields.successors;
delete fields.successorsTruncated;
delete fields.successorNote;
return fields as T;
}
/** What an authenticated non-owner caller may reach: its own seat, its rigs' seats, the items they send or own (to read),
* the items they own (to change), and its shared roots. A person (<entityId>@external) is never in a non-owner's scope. */
function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
@@ -391,6 +403,18 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
};
}
/** The daemon's root_unknown names every shared root it has. A scoped caller learns only the ones it may read: the
* daemon's roots its grant names, or none when that list can't be read. Every other answer passes as it is. */
function scopedRootUnknown(res: DaemonResponse<unknown>, scope: Scope): DaemonResponse<unknown> {
const data = (res.data ?? {}) as Record<string, unknown>;
if (data.error !== "root_unknown") return res;
const roots = Array.isArray(data.configuredRoots)
? data.configuredRoots.filter((name): name is string => typeof name === "string" && scope.root(name)) : [];
const named = typeof data.rootName === "string" ? ` '${data.rootName}'` : "";
return { status: res.status, data: { error: "root_unknown", ...(named ? { rootName: data.rootName } : {}), configuredRoots: roots,
message: `Shared root${named} is not configured on this daemon. Shared roots this connection may read: ${roots.join(", ") || "(none)"}.` } };
}
/** A target outside an authenticated caller's scope. It names the kind of target only, never the record. */
function outOfScope(what: string, hint?: string): TextResult {
const message = `${what} is outside what this connection may reach.${hint ? ` ${hint}` : ""}`;
@@ -609,8 +633,9 @@ const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
const tools = externalTools(access.external);
if (tools.has("rig_queue_claim") && tools.has("rig_queue_update")) return EXTERNAL_INBOX_INSTRUCTIONS;
return tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
}
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
@@ -1420,9 +1445,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const view = externalView(scope);
let successor = handedOn ? await successorOf(client, qitemId, row.closureTarget, view ? scope!.item : undefined) : undefined;
if (scope && !view && successor && "destinationSession" in successor && !scope.successor(successor.destinationSession)) successor = { unknown: true, code: "out_of_scope" };
const shownItem = scope ? withoutDaemonSuccessors(item.data) : item.data;
const data = view
? { item: projectForExternal(item.data, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
: { item: item.data, transitions: transitions.data };
? { item: projectForExternal(shownItem, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
: { item: shownItem, transitions: transitions.data };
return mapResult({ status: 200, data: { ...data, ...(handedOn ? { successor } : {}) } }, undefined, { structured: true });
} catch (err) {
return caught(err);
@@ -1679,18 +1705,33 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_team")) server.registerTool(
"rig_team",
{
description: "Show a rig's team: its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
description: "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
inputSchema: z.object({
rigId: z.string().describe("Rig name or identifier"),
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
}),
annotations: READ_ONLY,
},
async ({ rigId }) => {
try {
const inferred = rigId === undefined && !!access?.external;
if (rigId === undefined) {
if (!access?.external) {
const message = "Pass a rig name or identifier as rigId.";
return errorResult(message, "rig_required", message);
}
const rigs = [...new Set(access.rigs)];
if (rigs.length !== 1) return mapResult({ status: 200, data: { rigs,
message: rigs.length ? "Choose one of this grant's rigs and pass its name as rigId." : "This grant names no rigs; ask the connection operator to check its scope." } }, undefined, { structured: true });
rigId = rigs[0]!;
}
// 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;
if (inferred && !rig.resolved) {
const message = `No rig could be resolved from this grant's scope: ${rigId}. Check the connection's scope with its operator.`;
return errorResult(message, "scoped_rig_unresolved", message, { rigs: [rigId] });
}
const id = encodeURIComponent(rig.id);
const detail = await client.get(`/api/rigs/${id}`);
if (detail.status >= 400) return mapResult(detail);
@@ -1839,7 +1880,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const read = async (source: string, rootName: string, inside: string): Promise<TextResult> => {
const res = await client.get(`/api/files/read?${new URLSearchParams({ root: rootName, path: inside }).toString()}`);
if (res.status >= 400) return mapResult(res);
if (res.status >= 400) return mapResult(scope ? scopedRootUnknown(res, scope) : res);
return mapResult({ status: 200, data: artifactView(source, (res.data ?? {}) as FileRead, offset ?? 0) }, undefined, { structured: true });
};
if (scope && root && !scope.root(root)) return outOfScope(`Root ${root}`);
+42
View File
@@ -0,0 +1,42 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { parseSessionName } from "./session-name.js";
/** One of the rig's seats as the daemon records it: its logical id, its member id within its pod, and its folder, the
* part of its session name before `@`, which every recap reader uses. */
export interface RigSeat { logicalId: string; member: string; folder: string }
/** Where `rig context recap-write` writes a recap: `rigs/<rig>/seats/<seat>`. Without --rig or --seat it's the calling
* seat's folder (OPENRIG_SESSION_NAME). A name that is a seat's folder, or the caller's own, is used as given. Another
* name maps to a seat's folder only when it is exactly one seat's recorded logical id or member id in the rig's seats,
* so neither a suffix nor a dot is taken as identity. When the seats can't be read, or nothing matches, the name is
* written as given and a note says what was and wasn't checked. Nothing is refused. */
export function resolveRecapSeat(opts: { rig?: string; seat?: string; sessionName?: string; topologyRoot: string;
seats?: RigSeat[] | { unavailable: string } }): { rig: string; seat: string; notes: string[] } | { error: string } {
const own = opts.sessionName ? parseSessionName(opts.sessionName) : undefined;
const ownRig = own?.kind === "canonical" ? own.rig : undefined;
const rig = opts.rig ?? ownRig;
if (!rig) return { error: "Name the rig with --rig: OPENRIG_SESSION_NAME isn't set, so there's no seat to default to." };
const self = own?.kind === "canonical" && rig === own.rig ? own.member : undefined;
if (opts.seat === undefined) {
if (!self) return { error: `Name the seat with --seat: the part of its session name before @ (dev-qa for dev-qa@${rig}).` };
return { rig, seat: self, notes: [] };
}
const given = opts.seat;
if (given === self) return { rig, seat: given, notes: [] };
const fresh = !existsSync(join(opts.topologyRoot, "rigs", rig, "seats", given));
const where = "OpenRig reads a seat's recap from the folder named by the part of its session name before @.";
if (!opts.seats || "unavailable" in opts.seats) {
const why = opts.seats && "unavailable" in opts.seats ? ` (${opts.seats.unavailable})` : "";
return { rig, seat: given, notes: [`${fresh ? `seats/${given} didn't exist in rig ${rig}, so this write created it, and the` : `The recap went to the existing seats/${given} as given, but the`} name couldn't be checked against the rig's seats${why}. ${where}`] };
}
if (opts.seats.some((seat) => seat.folder === given)) return { rig, seat: given, notes: [] };
const matches = opts.seats.filter((seat) => seat.logicalId === given || seat.member === given);
if (matches.length === 1) {
const [seat] = matches;
const kind = seat!.logicalId === given ? "logical id" : "member id";
return { rig, seat: seat!.folder, notes: [`--seat ${given} is the ${kind} of ${seat!.folder}@${rig}, so the recap goes to seats/${seat!.folder}, the folder OpenRig reads its recap from.`] };
}
const named = matches.length ? ` It's the logical or member id of ${matches.map((seat) => `seats/${seat.folder}`).join(" and ")}, so it wasn't mapped; name one of them.` : "";
return { rig, seat: given, notes: [`seats/${given} ${fresh ? `didn't exist in rig ${rig}, so this write created it` : `isn't one of rig ${rig}'s seat folders`}. ${where}${named}`] };
}
+110
View File
@@ -0,0 +1,110 @@
// Seats that need attention, named where a command shows or reports them, so a held seat doesn't read as a view or
// kernel that failed for no reason (D#52). This reads the daemon's own attention flag and recorded reason from the
// node route `rig ps --nodes` uses; it detects nothing itself. A check that couldn't be made is reported as unknown,
// never as "nothing needs attention".
import type { DaemonClient } from "./client.js";
import { attentionReason, questionReason, servesQuestion, type NodeEntry } from "./commands/ps.js";
export interface SeatAttention {
seat: string;
reason: string;
/** The daemon serves a live question for this seat (needs-input), so a person can answer it there. Otherwise the
* flag is broader: a failed or gated startup, a recorded error, a held message or a missing required file. */
waitingOnPerson: boolean;
}
export interface AttentionCheck {
attention: SeatAttention[];
/** Seats whose attention couldn't be read, and why. */
unknown: Array<{ seat: string; why: string }>;
}
function failure(res: { status: number; data?: unknown }): string {
const error = (res.data as { error?: unknown } | null | undefined)?.error;
return `HTTP ${res.status}${typeof error === "string" && error ? `: ${error}` : ""}`;
}
/** The given local seats (session names, `member@rig`) checked against the daemon's node inventory. A rig whose read
* fails leaves its seats unknown and keeps the other rigs' answers. */
export async function checkSeatAttention(client: DaemonClient, seats: readonly string[]): Promise<AttentionCheck> {
const check: AttentionCheck = { attention: [], unknown: [] };
const byRig = new Map<string, Set<string>>();
for (const seat of seats) {
const at = seat.lastIndexOf("@");
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
const rig = seat.slice(at + 1);
if (!byRig.has(rig)) byRig.set(rig, new Set());
byRig.get(rig)!.add(seat);
}
if (byRig.size === 0) return check;
const unknownAll = (why: string) => { for (const wanted of byRig.values()) for (const seat of wanted) check.unknown.push({ seat, why }); };
let rigs: Array<{ rigId: string; rigName?: string; name?: string }>;
try {
const res = await client.get<Array<{ rigId: string; rigName?: string; name?: string }>>("/api/ps");
if (res.status >= 400 || !Array.isArray(res.data)) { unknownAll(`the rig list couldn't be read (${failure(res)})`); return check; }
rigs = res.data;
} catch (err) {
unknownAll(`the rig list couldn't be read (${err instanceof Error ? err.message : String(err)})`);
return check;
}
for (const [rigName, wanted] of byRig) {
const matches = rigs.filter(rig => (rig.rigName ?? rig.name) === rigName);
if (matches.length === 0) { for (const seat of wanted) check.unknown.push({ seat, why: `no rig named ${rigName} on this daemon` }); continue; }
const found = new Set<string>();
const failures: string[] = [];
for (const rig of matches) {
let nodes: NodeEntry[];
try {
const res = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
if (res.status >= 400 || !Array.isArray(res.data)) { failures.push(`${rigName}'s seats couldn't be read (${failure(res)})`); continue; }
nodes = res.data;
} catch (err) {
failures.push(`${rigName}'s seats couldn't be read (${err instanceof Error ? err.message : String(err)})`);
continue;
}
for (const node of nodes) {
const seat = node.canonicalSessionName;
if (!seat || !wanted.has(seat) || found.has(seat)) continue;
found.add(seat);
if (node.needsAttention === undefined) { check.unknown.push({ seat, why: "the daemon served no attention evidence for it" }); continue; }
if (!node.needsAttention) continue;
const question = servesQuestion(node);
// A live question names its own reason. An older recorded error would describe something else.
check.attention.push({ seat, reason: question ? questionReason(node) ?? "reason unavailable" : attentionReason(node), waitingOnPerson: question });
}
}
for (const seat of wanted) {
if (!found.has(seat)) check.unknown.push({ seat, why: failures[0] ?? `not in ${rigName}'s seats on this daemon` });
}
}
return check;
}
/** The kernel's seats that aren't ready, checked the same way. */
export async function checkKernelAttention(client: DaemonClient): Promise<AttentionCheck> {
try {
const res = await client.get<{ agents?: Array<{ session_name?: string; startup_status?: string; down?: boolean }> }>("/api/kernel/status");
if (res.status >= 400) return { attention: [], unknown: [{ seat: "kernel", why: `the kernel status couldn't be read (${failure(res)})` }] };
const unready = (res.data?.agents ?? [])
.filter(agent => agent.startup_status !== "ready" || agent.down)
.flatMap(agent => agent.session_name ? [agent.session_name] : []);
return unready.length ? checkSeatAttention(client, unready) : { attention: [], unknown: [] };
} catch (err) {
return { attention: [], unknown: [{ seat: "kernel", why: `the kernel status couldn't be read (${err instanceof Error ? err.message : String(err)})` }] };
}
}
/** One line per seat that needs attention, then one line per reason a check couldn't be made. */
export function attentionLines(check: AttentionCheck, where: "tile" | "pane"): string[] {
const lines = check.attention.map(({ seat, reason, waitingOnPerson }) => waitingOnPerson
? `${seat} is waiting on a person: ${reason}. Answer it in its ${where === "tile" ? "tile" : "terminal"}, or read it with: rig capture ${seat}`
: `${seat} needs attention: ${reason}. Look at it ${where === "tile" ? "in its tile " : ""}with: rig capture ${seat}`);
const byWhy = new Map<string, string[]>();
for (const { seat, why } of check.unknown) byWhy.set(why, [...(byWhy.get(why) ?? []), seat]);
for (const [why, seats] of byWhy) {
const named = seats.length > 5 ? `${seats.slice(0, 5).join(", ")} and ${seats.length - 5} more` : seats.join(", ");
lines.push(`Couldn't check whether ${named} ${seats.length === 1 ? "needs" : "need"} attention: ${why}.`);
}
return lines;
}
+17 -13
View File
@@ -19,6 +19,8 @@ interface Preview {
tmux?: { mode: "inherit" | "isolated" };
status: { launch?: { socketPath: string; session?: string } };
composed: { id: string; localTmuxServer?: readonly string[]; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
/** The composed view's remote tiles, complete; an older daemon's preview omits it, so their hosts are unknown. */
remote?: Array<{ seat: string; host: string }>;
}
export interface WindowDeps {
@@ -300,7 +302,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
return failed("The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.");
}
const launchTarget = preview.data.status.launch;
let { composed, planId } = preview.data;
let { composed, planId, remote } = 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) {
@@ -320,7 +322,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
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);
({ composed, planId, remote } = next.data);
if (!composed.opened.length) throw new Error("No conversations are attachable after opening the terminal.");
}
if (composed.kernelLayout) windowNotes.push(viewportColumns === undefined
@@ -520,25 +522,27 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
pane = placeholder;
} 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.
// Use the viewer's detached size only while splitting; always release to inherited client sizing.
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 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"]);
try {
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 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 runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
} finally {
await runTmux(["set-option", "-wu", "-t", `${viewer}:${name}`, "window-size"]);
}
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 runTmux(["select-window", "-t", `${viewer}:view-1`]);
if (!window) {
windowAttempted = true;
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."] };
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), ...(Array.isArray(remote) ? { remote } : {}), 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) {
if (windowAttempted && /-1743\b/.test(`${(err as Error).message} ${(err as { stderr?: string }).stderr ?? ""}`)) {
windowNotes.push("macOS denied Automation access. In System Settings > Privacy & Security > Automation, allow the calling app to control the terminal app before trying again.");
+2 -2
View File
@@ -185,7 +185,7 @@ describe("Capture CLI", () => {
const { out, err } = await run(["read@my-rig", "--readable"]);
expect(requests.at(-1)!.readable).toEqual({ width: 120, height: 40 });
expect(out).toEqual(["line1\nline2\n"]);
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its window was then given back its size and its own window-size]"]);
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its size was put back and its window-size override was unset]"]);
});
it("says so when a web terminal took the window over meanwhile, instead of claiming a give-back", async () => {
@@ -195,7 +195,7 @@ describe("Capture CLI", () => {
it("says when the size couldn't be put back, without claiming it was", async () => {
const { err } = await run(["sizeback@my-rig", "--readable"]);
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its own window-size was given back, but putting its size back failed: busy]"]);
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its window-size override was unset, but putting its size back failed: busy]"]);
});
it("a readable request waits out the daemon's readable budget; other captures keep the usual wait", async () => {
@@ -116,7 +116,9 @@ describe("DaemonClient response body deadline", () => {
it("uses the per-request deadline through the complete body", async () => {
const endpoint = await slowBodyServer(100);
const client = new DaemonClient(endpoint.url, { timeoutMs: 20 });
await expect(client.get("/read", { timeoutMs: 1_000 })).resolves.toEqual({ status: 200, data: { ok: true } });
const response = await client.get("/read", { timeoutMs: 1_000 });
expect(response).toEqual({ status: 200, data: { ok: true }, headers: expect.any(Headers) });
expect(response.headers?.get("content-type")).toBe("application/json");
expect(endpoint.requests()).toBe(1);
expect(endpoint.closedBeforeEnd()).toBe(0);
});
@@ -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_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_TERMINAL_FIXED_GEOMETRY", "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",
@@ -188,6 +188,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.fixed_geometry",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
"output.compact",
@@ -216,6 +217,9 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
"retention.events_days", // the history-only event types of events
"retention.closed_content_days", // closed-work erasure: content
"retention.closed_metadata_days", // closed-work erasure: the row, keeping its id
"retention.erasure_ledger", // queue erasure's replay ledger file
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
+131 -2
View File
@@ -14,6 +14,7 @@ import { contextCommand } from "../src/commands/context.js";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
import { resolveAuthoredRecapPointer } from "../../daemon/src/domain/context-packs/seat-recap-store.js";
/** A running daemon on `port`, as the CLI's status check sees it. */
function lifecycleDeps(port: number): LifecycleDeps {
@@ -34,6 +35,13 @@ function lifecycleDeps(port: number): LifecycleDeps {
const daemonDeps = (port: number): StatusDeps => ({ lifecycleDeps: lifecycleDeps(port), clientFactory: (url) => new DaemonClient(url) });
/** No daemon state file and nothing answering: the CLI reads the daemon as stopped. */
const noDaemon: StatusDeps = {
lifecycleDeps: { ...lifecycleDeps(0), readFile: vi.fn(() => null), exists: vi.fn(() => false), isProcessAlive: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("connect ECONNREFUSED"); }) },
clientFactory: () => { throw new Error("no daemon in this test"); },
};
async function runRecapWrite(argv: string[], deps?: StatusDeps): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
const logs: string[] = [];
const errLogs: string[] = [];
@@ -47,7 +55,8 @@ async function runRecapWrite(argv: string[], deps?: StatusDeps): Promise<{ logs:
try {
const program = new Command();
program.exitOverride();
program.addCommand(contextCommand(deps));
// No test reaches a real daemon: without deps, the daemon reads as stopped.
program.addCommand(contextCommand(deps ?? noDaemon));
await program.parseAsync(["node", "rig", "context", ...argv]);
} catch { /* commander exitOverride */ } finally {
exitCode = process.exitCode;
@@ -231,7 +240,9 @@ describe.skipIf(process.getuid?.() === 0)("rig context recap-write when this pro
const res = await runRecapWrite(["recap-write", "--rig", "r1", "--seat", "s1", "--file", recap], daemonDeps(port));
expect(res.exitCode ?? 0).toBe(0);
expect(received).toEqual([{ method: "POST", url: "/api/context-packs/seat-recap",
// The named seat is looked up first (reads only); the one write is the daemon's recap write.
expect(received.filter((request) => request.method !== "POST").every((request) => request.method === "GET")).toBe(true);
expect(received.filter((request) => request.method === "POST")).toEqual([{ method: "POST", url: "/api/context-packs/seat-recap",
body: { rig: "r1", seat: "s1", content: "## Recent Decisions\nchose the daemon because the seat can't write here" } }]);
expect(res.logs.join("\n")).toBe(`Recap written: /daemon/topology/rigs/r1/seats/s1/RECAP.md (1 superseded predecessor retained), by the daemon at http://127.0.0.1:${port}`);
expect(res.errLogs.join("\n")).toMatch(/can't write the topology tree \(EACCES.*\), so the daemon writes the recap instead/);
@@ -298,3 +309,121 @@ describe.skipIf(process.getuid?.() === 0)("rig context recap-write when this pro
}
});
});
// Every recap reader uses seats/<the part of the session name before @>, so a recap written under a member id or a
// logical id was left where nothing reads it (one seat had lead, editorial.lead and editorial-lead). A name maps only by
// the rig's recorded seats, never by its spelling: a suffix or a dot isn't identity.
describe("rig context recap-write — the seat folder every reader uses", () => {
/** The rig's seats as the daemon records them (pod.member logical ids; custom.name is a self-attached session). */
const recorded = [
{ logicalId: "editorial.lead", podNamespace: "editorial", canonicalSessionName: "editorial-lead@web-studio" },
{ logicalId: "ux.designer", podNamespace: "ux", canonicalSessionName: "ux-designer@web-studio" },
{ logicalId: "research.dev-qa", podNamespace: "research", canonicalSessionName: "research-dev-qa@web-studio" },
{ logicalId: "dev.qa", podNamespace: "dev", canonicalSessionName: "dev-qa@web-studio" },
{ logicalId: "custom", podNamespace: null, canonicalSessionName: "custom.name@web-studio" },
];
const withSeats: StatusDeps = { lifecycleDeps: lifecycleDeps(4242), clientFactory: (() => ({
get: async (path: string) => path === "/api/ps" ? { status: 200, data: [{ rigId: "rig-1", name: "web-studio" }] }
: path === "/api/rigs/rig-1/nodes" ? { status: 200, data: recorded } : { status: 404, data: {} },
})) as never };
async function inTopology(session: string | undefined, run: (tmp: string, seats: string, recap: string) => Promise<void>) {
const tmp = mkdtempSync(join(tmpdir(), "recap-seat-"));
const saved = { root: process.env["OPENRIG_TOPOLOGY_ROOT"], session: process.env["OPENRIG_SESSION_NAME"], legacy: process.env["RIGGED_SESSION_NAME"] };
try {
process.env["OPENRIG_TOPOLOGY_ROOT"] = join(tmp, "topology");
if (session === undefined) delete process.env["OPENRIG_SESSION_NAME"]; else process.env["OPENRIG_SESSION_NAME"] = session;
delete process.env["RIGGED_SESSION_NAME"];
const seats = join(tmp, "topology", "rigs", "web-studio", "seats");
for (const folder of ["editorial-lead", "ux-designer", "research-dev-qa", "dev-qa", "custom.name"]) mkdirSync(join(seats, folder), { recursive: true });
const recap = join(tmp, "recap.md");
writeFileSync(recap, "## Recent Decisions\nchose X because Y");
await run(tmp, seats, recap);
} finally {
for (const [key, value] of [["OPENRIG_TOPOLOGY_ROOT", saved.root], ["OPENRIG_SESSION_NAME", saved.session], ["RIGGED_SESSION_NAME", saved.legacy]] as const) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
rmSync(tmp, { recursive: true, force: true });
}
}
const folders = (seats: string) => readdirSync(seats).sort();
const standard = ["custom.name", "dev-qa", "editorial-lead", "research-dev-qa", "ux-designer"];
it.each([
{ name: "no --rig or --seat: this seat", args: [] as string[], note: undefined },
{ name: "this seat's member id", args: ["--seat", "lead"], note: "--seat lead is the member id of editorial-lead@web-studio, so the recap goes to seats/editorial-lead" },
{ name: "this seat's logical id", args: ["--seat", "editorial.lead"], note: "--seat editorial.lead is the logical id of editorial-lead@web-studio" },
{ name: "the folder itself", args: ["--rig", "web-studio", "--seat", "editorial-lead"], note: undefined },
])("a seat writing its own recap lands in seats/editorial-lead ($name)", async ({ args, note }) => {
await inTopology("editorial-lead@web-studio", async (tmp, seats, recap) => {
const res = await runRecapWrite(["recap-write", ...args, "--file", recap], withSeats);
expect(res.exitCode ?? 0).toBe(0);
expect(readFileSync(join(seats, "editorial-lead", "RECAP.md"), "utf-8")).toContain("chose X because Y");
// The handover's own reader finds it, from the session name alone.
expect(resolveAuthoredRecapPointer("editorial-lead@web-studio", join(tmp, "topology"))).toMatchObject({ address: "seat:RECAP.md" });
expect(folders(seats)).toEqual(standard);
if (note) expect(res.errLogs.join("\n")).toContain(note);
else expect(res.errLogs.join("\n")).not.toContain("Note:");
});
});
it("another seat's exact folder is never mistaken for the caller, even when the caller's folder ends with it", async () => {
await inTopology("research-dev-qa@web-studio", async (_tmp, seats, recap) => {
const res = await runRecapWrite(["recap-write", "--seat", "dev-qa", "--file", recap], withSeats);
expect(res.exitCode ?? 0).toBe(0);
expect(existsSync(join(seats, "dev-qa", "RECAP.md"))).toBe(true);
expect(existsSync(join(seats, "research-dev-qa", "RECAP.md"))).toBe(false);
expect(res.errLogs.join("\n")).not.toContain("Note:");
});
});
it.each(["custom.name@web-studio", "editorial-lead@web-studio"])("a dotted seat folder is used as given, not rewritten to a hyphen (caller %s)", async (session) => {
await inTopology(session, async (_tmp, seats, recap) => {
const res = await runRecapWrite(["recap-write", "--seat", "custom.name", "--file", recap], withSeats);
expect(res.exitCode ?? 0).toBe(0);
expect(existsSync(join(seats, "custom.name", "RECAP.md"))).toBe(true);
expect(folders(seats)).toEqual(standard);
});
});
it("another seat's recorded logical or member id maps to its folder; a name no seat has still writes, and says so", async () => {
await inTopology("editorial-lead@web-studio", async (_tmp, seats, recap) => {
const logical = await runRecapWrite(["recap-write", "--seat", "ux.designer", "--file", recap], withSeats);
expect(existsSync(join(seats, "ux-designer", "RECAP.md"))).toBe(true);
expect(logical.errLogs.join("\n")).toContain("--seat ux.designer is the logical id of ux-designer@web-studio");
const member = await runRecapWrite(["recap-write", "--seat", "designer", "--file", recap], withSeats);
expect(member.errLogs.join("\n")).toContain("--seat designer is the member id of ux-designer@web-studio");
expect(folders(seats)).toEqual(standard);
const unknown = await runRecapWrite(["recap-write", "--seat", "nobody", "--file", recap], withSeats);
expect(unknown.exitCode ?? 0).toBe(0);
expect(existsSync(join(seats, "nobody", "RECAP.md"))).toBe(true);
expect(unknown.errLogs.join("\n")).toContain("seats/nobody didn't exist in rig web-studio, so this write created it.");
});
});
it("when the daemon can't be asked, a name is written as given and the note says it wasn't checked", async () => {
await inTopology("editorial-lead@web-studio", async (_tmp, seats, recap) => {
const res = await runRecapWrite(["recap-write", "--seat", "lead", "--file", recap]);
expect(res.exitCode ?? 0).toBe(0);
expect(existsSync(join(seats, "lead", "RECAP.md"))).toBe(true);
expect(res.errLogs.join("\n")).toContain("the name couldn't be checked against the rig's seats");
// An existing folder, such as an old alias, is no proof of identity either, so the note is there too.
const existing = await runRecapWrite(["recap-write", "--seat", "lead", "--file", recap]);
expect(existing.exitCode ?? 0).toBe(0);
expect(existing.errLogs.join("\n")).toContain("The recap went to the existing seats/lead as given, but the name couldn't be checked against the rig's seats");
});
});
it("without OPENRIG_SESSION_NAME, --rig and --seat are named, not guessed", async () => {
await inTopology(undefined, async (_tmp, seats, recap) => {
const noRig = await runRecapWrite(["recap-write", "--file", recap]);
expect(noRig.exitCode).toBe(1);
expect(noRig.errLogs.join("\n")).toContain("Name the rig with --rig");
const noSeat = await runRecapWrite(["recap-write", "--rig", "web-studio", "--file", recap]);
expect(noSeat.exitCode).toBe(1);
expect(noSeat.errLogs.join("\n")).toContain("Name the seat with --seat");
expect(folders(seats)).toEqual(standard);
});
});
});
+112 -1
View File
@@ -5,7 +5,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
import { acquireDaemonStartLock, currentBootId } from "../src/daemon-start-lock.js";
import { createProcessLiveness, readProcessState } from "../src/commands/daemon.js";
afterEach(() => vi.useRealTimers());
function fixture() {
@@ -287,6 +288,116 @@ describe("local startup reservation", () => {
expect(fs.existsSync(file)).toBe(true); third.release();
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
describe("a reservation from before this boot", () => {
const startedAt = "2026-11-10T20:05:59.662Z", bootedAtMs = Date.parse("2026-11-10T20:46:15Z");
const dead = () => "dead" as const, alive = () => "alive" as const;
const leftover = (home: string, at = startedAt, bootId?: string) => {
const file = path.join(home, "daemon-start.lock");
fs.writeFileSync(file, JSON.stringify({ launcherPid: 35905, startedAt: at, ...(bootId ? { bootId } : {}) }) + "\n" + JSON.stringify({ childPid: 36449 }) + "\n");
const written = new Date(Date.parse(at) + 1000);
fs.utimesSync(file, written, written);
return file;
};
const archiveOf = (file: string, stamp: string) => `${file}.abandoned-${stamp}-${fs.statSync(file).ino}`;
const withHome = (body: (home: string) => void) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-start-lock-"));
try { body(home); } finally { fs.rmSync(home, { recursive: true, force: true }); }
};
it("is archived and the launch goes on when every recorded PID is dead", () => withHome((home) => {
const file = leftover(home), archive = archiveOf(file, "20261110T200559Z"), liveness = vi.fn(dead);
const lock = acquireDaemonStartLock(home, { bootedAtMs, processLiveness: liveness });
expect(lock.archived).toBe(archive);
expect(fs.readFileSync(archive, "utf8")).toContain('"childPid":36449');
expect(liveness).toHaveBeenCalledWith(35905); expect(liveness).toHaveBeenCalledWith(36449);
expect(fs.readFileSync(file, "utf8")).toContain(`"launcherPid":${process.pid}`);
lock.release();
expect(fs.existsSync(file)).toBe(false);
}));
it("stays when a recorded PID is alive, in case the clock moved after boot", () => withHome((home) => {
const file = leftover(home);
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: (pid) => pid === 36449 ? "alive" : "dead" })).toThrow(/already reserved/);
expect(fs.readFileSync(file, "utf8")).toContain('"childPid":36449');
}));
it("stays when a recorded PID can't be inspected (EPERM)", () => withHome((home) => {
const file = leftover(home);
const processLiveness = createProcessLiveness({ signal: () => "not-permitted", readProcessState: () => "S" });
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness })).toThrow(/already reserved/);
expect(fs.readdirSync(home)).toEqual(["daemon-start.lock"]);
expect(fs.readFileSync(file, "utf8")).toContain('"launcherPid":35905');
}));
it("stays when ps can't run for a recorded PID", () => withHome((home) => {
leftover(home);
const processLiveness = createProcessLiveness({
signal: () => "sent",
readProcessState: (pid) => readProcessState(pid, "darwin", () => { throw new Error("ps: not permitted"); }),
});
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness })).toThrow(/already reserved/);
expect(fs.readdirSync(home)).toEqual(["daemon-start.lock"]);
}));
it("stays when it was written during this boot", () => withHome((home) => {
leftover(home, "2026-11-10T20:50:00.000Z");
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
}));
it("stays when it was modified during this boot", () => withHome((home) => {
const file = leftover(home), later = new Date(bootedAtMs + 60_000);
fs.utimesSync(file, later, later);
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
}));
it("stays when it can't be read as a reservation", () => withHome((home) => {
const file = path.join(home, "daemon-start.lock");
fs.writeFileSync(file, "not json\n");
fs.utimesSync(file, new Date(bootedAtMs - 60_000), new Date(bootedAtMs - 60_000));
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
expect(fs.readFileSync(file, "utf8")).toBe("not json\n");
}));
it("is archived when it records an earlier boot's ID, even if a recorded PID is now someone else's", () => withHome((home) => {
const file = leftover(home, "2026-11-10T20:50:00.000Z", "boot-a"), archive = archiveOf(file, "20261110T205000Z");
const lock = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: alive });
expect(lock.archived).toBe(archive);
expect(fs.readFileSync(file, "utf8")).toContain('"bootId":"boot-b"');
lock.release();
}));
it("stays when it records this boot's ID, whatever its times and PIDs", () => withHome((home) => {
leftover(home, startedAt, "boot-b");
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
}));
it("without a boot ID of its own, falls back to the boot time and PID checks", () => withHome((home) => {
const file = leftover(home), archive = archiveOf(file, "20261110T200559Z");
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: () => "unknown" })).toThrow(/already reserved/);
const lock = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead });
expect(lock.archived).toBe(archive);
lock.release();
}));
it("has one winner when another launch archives it and takes the lock between this one's inspection and its claim", () => withHome((home) => {
const file = leftover(home, startedAt, "boot-a"), archive = archiveOf(file, "20261110T200559Z");
let b: ReturnType<typeof acquireDaemonStartLock> | undefined;
const afterInspect = () => { b = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead }); };
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead, afterInspect })).toThrow(/already reserved/);
expect(b?.archived).toBe(archive);
expect(fs.readFileSync(archive, "utf8")).toContain('"launcherPid":35905'); // the archive still holds the abandoned reservation
const current = fs.readFileSync(file, "utf8");
expect(current).toContain(`"launcherPid":${process.pid}`); // B's fresh lock is still in place, not moved by A
expect(fs.readdirSync(home).sort()).toEqual([path.basename(archive), "daemon-start.lock"].sort());
b!.release();
expect(fs.existsSync(file)).toBe(false); // B still owns its file
}));
it("is not moved when the path holds a different file than the one inspected", () => withHome((home) => {
const file = leftover(home, startedAt, "boot-a");
const afterInspect = () => { fs.renameSync(file, file + ".moved"); fs.writeFileSync(file, '{"launcherPid":1,"startedAt":"2026-11-10T21:00:00.000Z","bootId":"boot-b"}\n'); };
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead, afterInspect })).toThrow(/already reserved/);
expect(fs.readFileSync(file, "utf8")).toContain('"launcherPid":1');
expect(fs.readdirSync(home).sort()).toEqual(["daemon-start.lock", "daemon-start.lock.moved"]);
}));
it.runIf(process.platform === "darwin" || process.platform === "linux")("reads this boot's ID from the OS", () => {
const id = currentBootId();
expect(id).toMatch(/^[0-9a-f-]{32,36}$/i);
expect(currentBootId()).toBe(id);
});
it("stays without the boot check, as before", () => withHome((home) => {
leftover(home);
expect(() => acquireDaemonStartLock(home)).toThrow(/already reserved/);
}));
});
});
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { waitForKernelReady, type KernelReadyResult } from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
import { checkKernelAttention } from "../src/seat-attention.js";
vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
@@ -14,14 +15,22 @@ vi.mock("../src/daemon-lifecycle.js", async (original) => {
waitForKernelReady: vi.fn(),
};
});
// The held-seat read goes to the daemon this start reports; never a real one from a test.
vi.mock("../src/seat-attention.js", async (original) => ({
...await original<typeof import("../src/seat-attention.js")>(),
checkKernelAttention: vi.fn(async () => ({ attention: [], unknown: [] })),
}));
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
let home: string;
let errors: string[];
let logs: string[];
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "daemon-start-wait-for-kernel-"));
vi.stubEnv("OPENRIG_HOME", home);
errors = [];
vi.spyOn(console, "log").mockImplementation(() => {});
logs = [];
vi.mocked(checkKernelAttention).mockResolvedValue({ attention: [], unknown: [] });
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { logs.push(args.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.join(" ")); });
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
@@ -57,3 +66,19 @@ it("a kernel still booting at the deadline keeps the timeout and its fix", async
expect(out).toContain("Fix: inspect `rig ps --rig kernel` for stalled agents");
expect(process.exitCode).toBe(1);
});
it("a partial_ready kernel names the seat that needs attention and why, and any it couldn't check (D#52)", async () => {
vi.mocked(checkKernelAttention).mockResolvedValue({
attention: [{ seat: "advisor-lead@kernel", reason: "Startup requires attention: Login expired · Please run /login", waitingOnPerson: false }],
unknown: [{ seat: "queue-worker@kernel", why: "not in kernel's seats on this daemon" }] });
await startWaiting({ ok: true, kernelState: "partial_ready", variant: "claude-only" });
expect(logs).toContain("Kernel partial_ready; variant=claude-only");
expect(logs).toContain(" advisor-lead@kernel needs attention: Startup requires attention: Login expired · Please run /login. Look at it with: rig capture advisor-lead@kernel");
expect(logs).toContain(" Couldn't check whether queue-worker@kernel needs attention: not in kernel's seats on this daemon.");
expect(process.exitCode).toBeUndefined();
});
it("a ready kernel asks nothing more of the daemon", async () => {
await startWaiting({ ok: true, kernelState: "ready", variant: "claude-only" });
expect(checkKernelAttention).not.toHaveBeenCalled();
});
@@ -221,6 +221,89 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
if (loaded.ok) expect(loaded.entities[0]!.prefs.deliveryClass).toBe("D");
});
it("set binding.<n> unset refuses primary removal when secondary bindings exist, then unsets secondary and sole primary with remainingBindings + note", async () => {
const addRes = addHumanFragment(
{
entityId: "ana",
class: "human",
displayName: "Ana",
address: "ana@external",
connectorBindings: [
{ kind: "slack", connectorRef: "main", secretsRef: "vault://slack/ana-secret", role: "primary", handle: "U0ANA" },
{ kind: "slack", connectorRef: "backup", secretsRef: "vault://slack/ana-backup-secret", role: "secondary" },
],
prefs: { deliveryClass: "A" },
},
home,
);
expect(addRes.ok).toBe(true);
// 1. Refuse unsetting primary binding.0 while binding.1 remains
errSpy.mockClear();
const p1 = program();
p1.exitOverride();
await p1.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.0", "unset", "--reason", "try remove primary"]);
expect(process.exitCode).toBe(1);
const refusal = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(refusal).toContain("no replacement is chosen silently");
expect(refusal).toContain('To replace the primary, set binding.0 to the new primary spec: rig gateway human set ana binding.0 "<kind:connectorRef:secretsRef:primary[:handle=<id>]>"');
expect(refusal).toContain("To promote one of the other bindings (binding.1), first save that binding's full spec (kind:connectorRef:secretsRef:role[:handle=<id>]) from rig gateway human show ana --json, then unset that secondary slot, then find the primary's current slot in remainingBindings (or rig gateway human show ana --json, since unsetting a lower slot shifts the primary down) and set it to the saved spec with role primary (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary).");
expect(refusal).toContain("To remove every binding, unset the secondary bindings first using their current slot indexes from remainingBindings or rig gateway human show ana, then finish at binding.0 (once all secondaries are gone, the primary is at binding.0).");
expect(refusal).not.toContain("--replace");
// 2. Unset secondary binding.1 -> succeeds, prints remainingBindings[0] = binding.0 and note without secrets
process.exitCode = undefined;
logSpy.mockClear();
const p2 = program();
p2.exitOverride();
await p2.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.1", "unset", "--reason", "drop backup binding"]);
expect(process.exitCode).toBeUndefined();
const out1Raw = logSpy.mock.calls.at(-1)![0] as string;
expect(out1Raw).not.toContain("ana-secret");
const out1 = JSON.parse(out1Raw) as {
ok: boolean;
entityId: string;
field: string;
receipt: { effect: string };
remainingBindings: Array<Record<string, unknown>>;
note: string;
};
expect(out1.ok).toBe(true);
expect(out1.entityId).toBe("ana");
expect(out1.field).toBe("binding.1");
expect(out1.receipt.effect).toBe("applied");
expect(out1.remainingBindings).toEqual([
{ slot: "binding.0", kind: "slack", connectorRef: "main", role: "primary", handle: "U0ANA", inboundResolvable: true },
]);
expect(out1.note).toContain("Remaining binding(s): binding.0: slack:main role=primary handle=U0ANA");
expect(out1.note).toContain("Binding slot indexes shift after removal");
// 3. Unset sole remaining primary binding.0 -> succeeds, leaves connectorBindings: []
logSpy.mockClear();
const p3 = program();
p3.exitOverride();
await p3.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.0", "unset", "--reason", "remove last binding"]);
expect(process.exitCode).toBeUndefined();
const out2 = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as {
ok: boolean;
remainingBindings: unknown[];
note: string;
};
expect(out2.ok).toBe(true);
expect(out2.remainingBindings).toEqual([]);
expect(out2.note).toContain("Remaining bindings: none");
expect(out2.note).toContain("Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain)");
expect(out2.note).toContain("It does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted");
const loaded = loadHumanRegistry(home);
expect(loaded.ok).toBe(true);
if (loaded.ok) {
const ana = loaded.entities.find((e) => e.entityId === "ana")!;
expect(ana.connectorBindings).toEqual([]);
expect(ana.prefs.deliveryClass).toBe("A");
}
});
it("set with a bad enum exits 1 and names the allowed set", async () => {
const p = program();
p.exitOverride();
+211
View File
@@ -0,0 +1,211 @@
// rig host approve|deny <request>: the explicit pairing decision on this host, from the agent that reads the person's
// answer or from a person at a plain terminal. A local HTTP server stands in for the daemon; no real token.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { Command } from "commander";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { hostCommand } from "../src/commands/host.js";
import { operatorAuthHeaders, terminalAuthHeaders } from "../src/client.js";
import { createFullTestDb } from "../../daemon/test/helpers/test-app.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { makeTopologyDestinationCheck } from "../../daemon/src/domain/queue-destination-check.js";
import { hostsRoutes } from "../../daemon/src/routes/hosts.js";
async function run(argv: string[]): 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(" "));
try {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
await prog.parseAsync(["node", "rig", "host", ...argv]);
} finally { console.log = ol; console.error = oe; }
const exitCode = process.exitCode as number | undefined;
process.exitCode = oc;
return { out: out.join("\n"), err: err.join("\n"), exitCode };
}
describe("rig host approve|deny", () => {
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: http.Server;
let requests: Array<{ url: string; headers: http.IncomingHttpHeaders; body: string }>;
let reply: { status: number; body: unknown };
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdecide-"));
savedEnv = {};
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN"]) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
process.env.OPENRIG_HOME = dir;
// A plain terminal's write credential (terminalAuthHeaders reads it from the OpenRig home).
fs.writeFileSync(path.join(dir, "terminal-token"), "synthetic-terminal-token\n");
requests = [];
reply = { status: 200, body: { ok: true, decision: "approve", code: "424242", requester: "alex@laptop", by: "human@host" } };
server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => { body += c; });
req.on("end", () => {
requests.push({ url: req.url ?? "", headers: req.headers, body });
res.writeHead(reply.status, { "Content-Type": "application/json" });
res.end(JSON.stringify(reply.body));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
process.env.OPENRIG_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
afterEach(() => {
server.close();
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;
}
});
it("approve from a plain terminal: one authenticated write naming the request, no seat, and the result printed", async () => {
const { out, err, exitCode } = await run(["approve", "424242"]);
expect(exitCode).toBeUndefined();
expect(err).toBe("");
expect(requests).toHaveLength(1);
expect(requests[0]!.url).toBe("/api/hosts/pair-decision");
expect(JSON.parse(requests[0]!.body)).toEqual({ request: "424242", decision: "approve" });
expect(requests[0]!.headers.authorization).toBe("Bearer synthetic-terminal-token");
expect(requests[0]!.headers["x-openrig-session"]).toBeUndefined();
expect(out).toBe("Approved pairing 424242 from alex@laptop, as human@host. The requesting machine receives this host's token when it next checks.");
});
it("deny from a seat carries the seat, and a refusal prints the daemon's message and exits 1", async () => {
process.env.OPENRIG_SESSION_NAME = "operator-agent@kernel";
reply = { status: 409, body: { error: "pair_request_decided", decision: "approve", by: "human@host",
message: "Pairing request 424242 was already approved by human@host; that stands." } };
const { out, err, exitCode } = await run(["deny", "qitem-pair-1"]);
expect(exitCode).toBe(1);
expect(out).toBe("");
expect(err).toBe("Pairing request 424242 was already approved by human@host; that stands.");
expect(JSON.parse(requests[0]!.body)).toEqual({ request: "qitem-pair-1", decision: "deny" });
expect(requests[0]!.headers["x-openrig-session"]).toBe("operator-agent@kernel");
});
});
// The real route behind the real command, where the daemon's operator bearer and the terminal token differ: the decision
// takes the operator bearer, which an agent seat carries (OPENRIG_AUTH_BEARER_TOKEN) and a person passes with --bearer.
describe("rig host approve against the real pairing route, with distinct credentials", () => {
const AUTH = "synthetic-operator-bearer", TERMINAL = "synthetic-terminal-token-differs";
const PERSON = "pair-person@external";
const registry = { ok: true as const, entities: [{ entityId: "pair-person", class: "human" as const, displayName: "Pairing person", address: PERSON,
connectorBindings: [], prefs: { deliveryClass: "A" as const } }] };
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: { close: () => void };
let base: string;
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdecide-real-"));
savedEnv = {};
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN", "OPENRIG_AUTH_BEARER_TOKEN"]) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
process.env.OPENRIG_HOME = dir;
fs.writeFileSync(path.join(dir, "terminal-token"), `${TERMINAL}\n`);
const db = createFullTestDb();
const bus = new EventBus(db);
const validateRig = makeTopologyDestinationCheck({ findRigsByName: () => [], loadHumanRegistry: () => registry, sharedChannels: () => [] });
const repo = new QueueRepository(db, bus, { validateRig, loadHumanRegistry: () => registry,
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
const app = new Hono();
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); await next(); });
app.route("/api/hosts", hostsRoutes({ bearerToken: AUTH, humanRegistry: () => registry }));
await new Promise<void>((resolve) => {
const s = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { base = `http://127.0.0.1:${info.port}`; resolve(); });
server = { close: () => { s.close(); db.close(); } };
});
process.env.OPENRIG_URL = base;
});
afterEach(() => {
server.close();
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 request(): Promise<{ pairId: string; code: string }> {
return (await fetch(`${base}/api/hosts/pair-request`, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requester: "requester@far-host" }) })).json() as Promise<{ pairId: string; code: string }>;
}
const poll = async (pairId: string) => (await fetch(`${base}/api/hosts/pair-request/${pairId}`)).json() as Promise<{ status: string; token?: string }>;
it("from an agent seat (OPENRIG_AUTH_BEARER_TOKEN in its env, a different terminal token on disk): approved, and the token is handed over", async () => {
const pair = await request();
process.env.OPENRIG_SESSION_NAME = "operator-agent@kernel";
process.env.OPENRIG_AUTH_BEARER_TOKEN = AUTH;
const { out, exitCode } = await run(["approve", pair.code]);
expect(exitCode).toBeUndefined();
expect(out).toContain(`Approved pairing ${pair.code} from requester@far-host, as operator-agent@kernel.`);
expect(await poll(pair.pairId)).toEqual({ status: "approved", token: AUTH });
});
it("from a plain terminal with --bearer: denied, recorded as a direct human action", async () => {
const pair = await request();
const { out, exitCode } = await run(["deny", pair.code, "--bearer", AUTH]);
expect(exitCode).toBeUndefined();
expect(out).toContain(`Denied pairing ${pair.code} from requester@far-host, as human@host.`);
expect(await poll(pair.pairId)).toEqual({ status: "denied" });
});
it("with only a terminal token that isn't the operator bearer: refused with what to set, and nothing is decided", async () => {
const pair = await request();
const { err, exitCode } = await run(["approve", pair.code]);
expect(exitCode).toBe(1);
expect(err).toBe("This daemon refused the decision without its operator bearer token. Set OPENRIG_AUTH_BEARER_TOKEN to the token this daemon runs with, or pass --bearer <token>. Nothing was decided.");
expect(await poll(pair.pairId)).toMatchObject({ status: "pending" });
});
});
describe("operatorAuthHeaders: the credential for operator-gated writes", () => {
let dir: string;
let saved: Record<string, string | undefined>;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "operator-auth-"));
saved = {};
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_TERMINAL_BEARER_TOKEN", "OPENRIG_AUTH_BEARER_TOKEN"]) { saved[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(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
});
it("takes the configured operator bearer with no terminal-token file, while terminal calls carry none", () => {
process.env.OPENRIG_AUTH_BEARER_TOKEN = "synthetic-operator-bearer";
expect(operatorAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-operator-bearer" });
expect(terminalAuthHeaders()).toEqual({});
});
it("an explicit bearer wins; with none configured it falls back to the terminal credential, as before", () => {
fs.writeFileSync(path.join(dir, "terminal-token"), "synthetic-terminal-token\n");
process.env.OPENRIG_AUTH_BEARER_TOKEN = "synthetic-operator-bearer";
expect(operatorAuthHeaders("synthetic-explicit")).toEqual({ Authorization: "Bearer synthetic-explicit" });
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
expect(operatorAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-terminal-token" });
expect(terminalAuthHeaders()).toEqual({ Authorization: "Bearer synthetic-terminal-token" });
});
});
+24
View File
@@ -81,6 +81,10 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: true, entry: { id: "vps-a", transport: "http" } });
} else {
expect(out.join("\n")).toContain("Pairing code: 424242");
// The person on the target answers however they like (TUI, Steering, Slack); its kernel operator, or a terminal, decides.
expect(out.join("\n")).toContain("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
expect(out.join("\n")).toContain("On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve 424242 (or rig host deny 424242), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.");
expect(out.join("\n")).not.toContain("On the target: rig queue update");
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
expect(out.join("\n")).toContain("rig host doctor vps-a");
expect(out.join("\n")).toContain("rig config set feed.subscriptions.vps-a.enabled true");
@@ -96,6 +100,26 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
expect(yaml).not.toContain("issued-bearer");
}, 15_000);
it("a target that says who was asked and who reads the answer is named; one where no agent reads says why", async () => {
const approved = [{ status: 200, body: { status: "approved", token: "issued-bearer" } }];
const owned = fakeDeps({ post: { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1", person: "alex@external", owner: "lead@rig" } }, poll: approved });
const a = await capture(() => run(owned, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(a.out.join("\n")).toContain("The request went to alex@external on the target.\nlead@rig reads their answer and decides.");
expect(a.out.join("\n")).not.toContain("On the target, its person answers the pairing request");
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const free = fakeDeps({ post: { status: 200, body: { pairId: "p-2", code: "515151", approvalQitemId: "qitem-2", person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" } }, poll: approved });
const b = await capture(() => run(free, ["pair", "vps-b:7433", "--id", "vps-b", "--timeout", "10"]));
expect(b.out.join("\n")).toContain("On the target: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). The request names how to decide directly on the target.");
// A target that couldn't tell is printed as unknown, never as "nobody reads".
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const unknown = fakeDeps({ post: { status: 200, body: { pairId: "p-3", code: "616161", approvalQitemId: "qitem-3", person: "alex@external", owner: null,
noReader: "OpenRig couldn't tell whether an agent reads answers on this host" } }, poll: approved });
const c = await capture(() => run(unknown, ["pair", "vps-c:7433", "--id", "vps-c", "--timeout", "10"]));
expect(c.out.join("\n")).toContain("On the target: OpenRig couldn't tell whether an agent reads answers on this host. The request names how to decide directly on the target.");
expect(c.out.join("\n")).not.toContain("No agent reads answers there");
}, 15_000);
it("--timeout reaches the target, which keeps the request open that long", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { parseLogicalCheckboxes } from "../src/lib/scope/logical-checkbox.js";
import { parseProofContract } from "../src/commands/proof.js";
describe("proof-contract fenced examples", () => {
it.each(["```", "~~~"])("excludes examples inside %s fences and keeps source lines", (marker) => {
const block = [
"- [ ] first promise",
" continued",
`${marker}markdown`,
"- [x] example only",
marker,
"- [x] second promise",
].join("\n");
expect(parseLogicalCheckboxes(block)).toEqual([
{ checked: false, rawText: "first promise continued", sourceLine: 1 },
{ checked: true, rawText: "second promise", sourceLine: 6 },
]);
});
it("keeps shorter, mismatched and annotated closers inside the example", () => {
const block = ["````md", "```", "~~~", "```` text", "- [ ] example", "`````", "- [ ] real"].join("\n");
expect(parseLogicalCheckboxes(block)).toEqual([
{ checked: false, rawText: "real", sourceLine: 7 },
]);
});
it("does not treat inline backticks as an opener", () => {
expect(parseLogicalCheckboxes("```inline```\n- [ ] real")).toEqual([
{ checked: false, rawText: "real", sourceLine: 2 },
]);
});
it("keeps evidence positions stable through the proof-contract reader", () => {
const document = "## Proof contract\n- [ ] first\n```md\n- [x] example\n```\n- [ ] second\n";
expect(parseProofContract(document)).toEqual(["first", "second"]);
});
it("ignores the remainder of an unclosed example", () => {
expect(parseLogicalCheckboxes("~~~md\n- [ ] example")).toEqual([]);
});
});
+2 -1
View File
@@ -55,7 +55,8 @@ describe("a request allowed 300 s or more", () => {
it("carries the method, the seat and the body, and returns the daemon's answer as it came", async () => {
const client = new DaemonClient(baseUrl, { sessionName: "dev-impl@my-rig" });
const res = await client.post("/api/transport/send", { session: "qa@my-rig", waitForIdleMs: 400_000 }, { timeoutMs: 405_000 });
expect(res).toEqual({ status: 409, data: { ok: false, reason: "wait_for_idle_timeout", sent: false } });
expect(res).toEqual({ status: 409, data: { ok: false, reason: "wait_for_idle_timeout", sent: false }, headers: expect.any(Headers) });
expect(res.headers?.get("content-type")).toBe("application/json");
expect(seen.at(-1)).toEqual({ method: "POST", path: "/api/transport/send", session: "dev-impl@my-rig",
body: JSON.stringify({ session: "qa@my-rig", waitForIdleMs: 400_000 }) });
});
+40 -1
View File
@@ -34,6 +34,7 @@ describe("rig mcp serve --http on the daemon's routes", () => {
let db: Database.Database;
let home: string;
let shared: string;
let otherShared: string;
let repo: QueueRepository;
let bus: EventBus;
/** Registered people a test sets; null reads the instance's registry, as the daemon does. */
@@ -52,6 +53,7 @@ describe("rig mcp serve --http on the daemon's routes", () => {
migrate(db, ALL_MIGRATIONS);
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-http-"));
shared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "mcp-http-shared-")));
otherShared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "mcp-http-acct-b-")));
process.env.OPENRIG_HOME = home;
// The env seat a local caller is declared as; an authenticated caller must never act as it.
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
@@ -68,7 +70,8 @@ describe("rig mcp serve --http on the daemon's routes", () => {
appDeps: {
sessionTransport: { resolveSessions: async (target: unknown) => { resolved.push(target); return { ok: false, code: "not_found", error: "inert fixture" }; }, send: inert, capture: inert, broadcast: inert } as never,
queueRepo: repo,
filesAllowlist: [{ name: "proof", canonicalPath: shared }],
// Two shared roots, as one daemon serving two accounts has: proof, and another account's acct-b.
filesAllowlist: [{ name: "proof", canonicalPath: shared }, { name: "acct-b", canonicalPath: otherShared }],
},
});
const { app } = setup;
@@ -84,6 +87,7 @@ describe("rig mcp serve --http on the daemon's routes", () => {
db.close();
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(shared, { recursive: true, force: true });
fs.rmSync(otherShared, { recursive: true, force: true });
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
@@ -242,7 +246,18 @@ describe("rig mcp serve --http on the daemon's routes", () => {
const own = (await asCarol("rig_queue_create", { destination: "impl@team", body: "hand this on" })).structured.qitemId as string;
const handoff = await fetch(`${daemonUrl}/api/queue/${own}/handoff`, { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "impl@team" }, body: JSON.stringify({ toSession: "qa@other", nudge: false }) });
expect(handoff.status).toBe(201);
const next = ((await handoff.json()) as { created: { qitemId: string } }).created.qitemId;
expect((await asCarol("rig_queue_show", { qitemId: own })).structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
// Nor do the daemon's own successor fields, which it fills without a caller's scope (here: 21 successors, so the list
// is cut too).
const more = db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body, handed_off_from)
VALUES (?, '2026-10-26T00:00:00.000Z', '2026-10-26T00:00:00.000Z', 'impl@team', 'qa@other', 'pending', 'routine', 'x', ?)`);
for (let n = 0; n < 20; n++) more.run(`qitem-more-${n}`, own);
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ successorsTruncated: true, successors: expect.arrayContaining([expect.objectContaining({ qitemId: next })]) });
const handedOn = await asCarol("rig_queue_show", { qitemId: own });
for (const derived of ["successors", "successorsTruncated", "successorNote"]) expect(handedOn.structured.item).not.toHaveProperty(derived);
expect(handedOn.text).not.toContain(next);
expect(handedOn.text).not.toContain("qitem-more-");
// Artifacts: a root outside the grant is out of reach, directly or through an item.
fs.writeFileSync(path.join(shared, "report.md"), "ARTIFACT body");
@@ -254,6 +269,30 @@ describe("rig mcp serve --http on the daemon's routes", () => {
expect(viaItem.text).not.toContain("ARTIFACT body");
});
it("a scoped grant's root_unknown names only the shared roots it may read, and an unscoped caller still sees them all", async () => {
// The grant names proof and a root this daemon no longer shares; acct-b is another account's.
const dana = await grant("dana", { roots: ["proof", "stale"] });
const owner = await grant("owner", { owner: true });
const served = await listen();
const asDana = await connect(served.url, dana);
const asOwner = await connect(served.url, owner);
const withRef = await repo.create({ sourceSession: "dana@team", destinationSession: "impl@team", body: "x", nudge: false } as never);
db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run("report.md", withRef.qitemId);
for (const args of [{ root: "stale", path: "report.md" }, { qitemId: withRef.qitemId, root: "stale" }]) {
const scoped = await asDana("rig_artifact_read", args);
expect(scoped.structured).toMatchObject({ ok: false, code: "root_unknown",
message: "Shared root 'stale' is not configured on this daemon. Shared roots this connection may read: proof." });
expect(JSON.parse(scoped.text)).toEqual({ error: "root_unknown", rootName: "stale", configuredRoots: ["proof"],
message: "Shared root 'stale' is not configured on this daemon. Shared roots this connection may read: proof." });
}
const unscoped = await asOwner("rig_artifact_read", { root: "stale", path: "report.md" });
expect(unscoped.structured).toMatchObject({ code: "root_unknown" });
expect(JSON.parse(unscoped.text)).toMatchObject({ rootName: "stale", configuredRoots: ["proof", "acct-b"] });
expect(unscoped.text).toContain("acct-b");
});
it("a hosted member works its own rows as its grant's seat, shows on the team with activity unknown, and keeps its record after revoke", async () => {
// The team has a lead with a node. The hosted member (a tool outside any terminal) has a roster entry and a grant, no node.
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-http-workspace-"));
@@ -963,6 +963,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(shown.structured.item).toMatchObject({ state: "handed-off", closureReason: "handed_off_to", handedOffTo: null, closureTarget: null,
closingTransition: { closureReason: "handed_off_to", closureTarget: null, transitionNote: "handed on (out of scope)" } });
expect(shown.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
for (const derived of ["successors", "successorsTruncated", "successorNote"]) expect(shown.structured.item).not.toHaveProperty(derived);
expect(JSON.stringify((await asExternal("rig_queue_list", { all: true })).structured)).not.toContain("impl@team-a");
// A cross-host successor's parties can't be checked here: its id and host stay hidden.
@@ -970,6 +971,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
db.prepare("UPDATE queue_items SET state = 'handed-off', closure_reason = 'handed_off_to', closure_target = 'qitem-far01@other-host', handed_off_to = 'impl@team-a@other-host' WHERE qitem_id = ?").run(remote);
const far = await asExternal("rig_queue_show", { qitemId: remote });
expect(far.structured.successor).toEqual({ unknown: true, code: "out_of_scope" });
expect(far.structured.item).not.toHaveProperty("successorNote");
expect(far.text).not.toContain("other-host");
// A successor addressed to the caller but sent by another caller-shaped seat isn't the team's; nor is a sibling the
@@ -132,10 +132,12 @@ describe("agent network increment A: OAuth-connected assistants work with each o
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const team = await a("rig_team", { rigId: RIG });
const team = await a("rig_team");
expect(team.isError).toBe(false);
expect(team.text).toContain(B.seat);
expect(team.text).toContain(LEAD);
expect(team.structured.rig.name).toBe(RIG);
expect((await b("rig_team")).structured.rig.name).toBe(RIG);
const sent = await a("rig_queue_create", { destination: B.seat, body: "Critique this plan.", tags: ["review"] });
expect(sent.isError).toBe(false);
@@ -149,6 +149,8 @@ describe("MCP queue tools on the daemon's queue routes", () => {
const shown = await desk("rig_queue_show", { qitemId });
expect(shown.data.item).toMatchObject({ state: "handed-off" });
// An unscoped caller gets the daemon's successor fields as they are.
expect(shown.data.item.successors).toEqual([{ qitemId: next, destinationSession: "qa@team", state: "pending", via: "handedOffFrom" }]);
expect(shown.structured).toMatchObject({ successor: { qitemId: next, destinationSession: "qa@team", state: "pending" } });
expect(shown.data.successor).toEqual((shown.structured as { successor: unknown }).successor);
const onward = await desk("rig_queue_list", { handedOffFrom: qitemId });
+62 -4
View File
@@ -4,7 +4,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../src/mcp-server.js";
import { type McpAccess, createMcpServer } from "../src/mcp-server.js";
import { DaemonClient, type DaemonResponse } from "../src/client.js";
const RIG_ID = "01M5F0YZQXETQ4YVH3MJVQPQXQ";
@@ -14,7 +14,7 @@ const REMOVED_ID = "01M5F0REMOVEDRIGIDXXXXXXXX";
let close: (() => Promise<void>) | undefined;
afterEach(async () => { await close?.(); close = undefined; });
async function connect(rigs: Array<{ id: string; name: string }>) {
async function connect(rigs: Array<{ id: string; name: string }>, access?: McpAccess) {
const requests: string[] = [];
const snapshots: Record<string, unknown[]> = { [RIG_ID]: [{ id: "snap-1" }], [REMOVED_ID]: [{ id: "snap-old" }] };
const known = new Set(rigs.map((r) => r.id));
@@ -24,6 +24,8 @@ async function connect(rigs: Array<{ id: string; name: string }>) {
if (path.startsWith("/api/rigs/summary")) return { status: 200, data: rigs.map((r) => ({ ...r, archivedAt: null })) };
const snaps = /^\/api\/rigs\/([^/]+)\/snapshots$/.exec(path);
if (snaps) return { status: 200, data: snapshots[snaps[1]!] ?? [] };
const detail = /^\/api\/rigs\/([^/]+)$/.exec(path);
if (detail && known.has(detail[1]!)) return { status: 200, data: { rig: rigs.find(r => r.id === detail[1]), representative: null } };
const nodes = /^\/api\/rigs\/([^/]+)\/nodes$/.exec(path);
if (nodes && known.has(nodes[1]!)) return { status: 200, data: [{ logicalId: "work.worker" }] };
return { status: 404, data: { error: "Rig not found" } };
@@ -35,7 +37,7 @@ async function connect(rigs: Array<{ id: string; name: string }>) {
return { status: 404, data: { error: "Rig not found" } };
}) as DaemonClient["post"];
const server = createMcpServer(client);
const server = createMcpServer(client, { access });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const mcp = new Client({ name: "test", version: "1.0.0" });
await server.connect(serverTransport);
@@ -45,7 +47,7 @@ async function connect(rigs: Array<{ id: string; name: string }>) {
const result = await mcp.callTool({ name, arguments: args }) as { content: Array<{ text: string }>; isError?: boolean };
return { text: result.content[0]!.text, isError: result.isError === true };
};
return { call, requests };
return { call, requests, instructions: mcp.getInstructions(), tools: (await mcp.listTools()).tools };
}
describe("MCP rig tools accept a rig name", () => {
@@ -93,3 +95,59 @@ describe("MCP rig tools accept a rig name", () => {
expect(requests).toContain(`GET /api/rigs/${RIG_ID}/nodes`);
});
});
const external = (rigs: string[]): McpAccess => ({ principal: "fixture-caller@solo", owner: false, rigs, roots: [], workspace: false, external: { objects: { team: ["list"] } } });
describe("external team discovery within the grant", () => {
it.each([["solo"], ["solo", "solo"]])("uses a sole granted rig without a name (scope %j)", async (...rigs) => {
const { call, instructions, tools } = await connect([{ id: RIG_ID, name: "solo" }, { id: OTHER_ID, name: "outside" }], external(rigs));
const result = await call("rig_team", {});
expect(result.isError).toBe(false);
expect(JSON.parse(result.text).rig).toEqual({ id: RIG_ID, name: "solo" });
expect(result.text).not.toContain("outside");
expect(instructions?.slice(0, 512)).toContain("rig_team without rigId");
expect(tools.map(t => t.name)).toEqual(["rig_team"]);
});
it("offers only the granted names when a choice is needed, without looking up other rigs", async () => {
const { call, requests } = await connect([{ id: RIG_ID, name: "solo" }, { id: OTHER_ID, name: "outside" }], external(["solo", "second"]));
const result = await call("rig_team", {});
expect(result.isError).toBe(false);
expect(JSON.parse(result.text).rigs).toEqual(["solo", "second"]);
expect(result.text).not.toContain("outside");
expect(requests).toEqual([]);
});
it("names an empty grant scope without guessing another rig", async () => {
const { call, requests } = await connect([{ id: RIG_ID, name: "outside" }], external([]));
const result = await call("rig_team", {});
expect(result.isError).toBe(false);
expect(JSON.parse(result.text)).toMatchObject({ rigs: [], message: expect.stringContaining("no rigs") });
expect(result.text).not.toContain("outside");
expect(requests).toEqual([]);
});
it("names an unresolved sole grant and does not read a different rig", async () => {
const { call, requests } = await connect([{ id: OTHER_ID, name: "outside" }], external(["missing"]));
const result = await call("rig_team", {});
expect(result.isError).toBe(true);
expect(result.text).toContain("missing");
expect(result.text).not.toContain("outside");
expect(requests).not.toContain(`GET /api/rigs/${OTHER_ID}`);
});
it("still asks an unscoped owner for a rig rather than guessing one", async () => {
const { call, requests } = await connect([{ id: RIG_ID, name: "solo" }]);
const result = await call("rig_team", {});
expect(result.isError).toBe(true);
expect(result.text).toContain("Pass a rig name or identifier");
expect(requests).toEqual([]);
});
it("keeps explicit granted reads and out-of-scope refusal unchanged", async () => {
const { call, requests } = await connect([{ id: RIG_ID, name: "solo" }, { id: OTHER_ID, name: "outside" }], external(["solo"]));
expect((await call("rig_team", { rigId: "solo" })).isError).toBe(false);
expect((await call("rig_team", { rigId: "outside" })).isError).toBe(true);
expect(requests).not.toContain(`GET /api/rigs/${OTHER_ID}`);
});
});
+23
View File
@@ -354,6 +354,29 @@ describe("MCP Server", () => {
await cleanup();
});
it("rig_artifact_read: an external grant's root_unknown names only its own shared roots, or none when the daemon's list can't be read", async () => {
let listed = true;
const getFn = vi.fn(async (p: string) => {
if (p === "/api/queue/q-ext") return { status: 200, data: { qitemId: "q-ext", sourceSession: "cus-a1-caller@team", destinationSession: "lead@team", evidenceRef: "report.md" } };
if (p === "/api/files/roots") return { status: 200, data: { roots: [{ name: "proof", path: "/shared/proof" }, { name: "acct-b", path: "/shared/acct-b" }] } };
if (p.startsWith("/api/files/read?root=stale&")) return { status: 400, data: { error: "root_unknown",
message: "allowlist root 'stale' is not configured. Allowlist configured roots: proof, acct-b.", rootName: "stale", ...(listed ? { configuredRoots: ["proof", "acct-b"] } : {}) } };
return { status: 404, data: {} };
});
await setup({ get: getFn }, { seat: "cus-a1-caller@team",
access: { principal: "cus_A1", owner: false, rigs: ["team"], roots: ["proof", "stale"], workspace: false, external: { objects: { artifact: ["read"] } } } });
const named = await mcpClient.callTool({ name: "rig_artifact_read", arguments: { qitemId: "q-ext", root: "stale" } });
expect(named.structuredContent).toMatchObject({ ok: false, code: "root_unknown",
message: "Shared root 'stale' is not configured on this daemon. Shared roots this connection may read: proof." });
expect(JSON.stringify(named.content)).not.toContain("acct-b");
listed = false;
const unlisted = await mcpClient.callTool({ name: "rig_artifact_read", arguments: { qitemId: "q-ext", root: "stale" } });
expect(unlisted.structuredContent).toMatchObject({ code: "root_unknown",
message: "Shared root 'stale' is not configured on this daemon. Shared roots this connection may read: (none)." });
expect(JSON.stringify(unlisted.content)).not.toContain("acct-b");
await cleanup();
});
it("a front door gets front-door instructions, not the member workflow, and its declared seat with no session reads as itself", async () => {
try {
vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@my-rig");
+41 -1
View File
@@ -3,10 +3,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const calls: Array<{ method: string; url: string; body?: Record<string, unknown> }> = [];
let readExtra: Record<string, unknown> = {};
let readFailure: { status: number; data: Record<string, unknown> } | null = null;
vi.mock("../src/client.js", () => ({
DaemonClient: class {
async get(url: string) {
calls.push({ method: "GET", url });
if (readFailure) return readFailure;
return { status: 200, data: { items: [{ id: "i1", text: "Prove it.", index: 1, revision: "r1", judgment: null }], ...readExtra } };
}
async post(url: string, body: Record<string, unknown>) {
@@ -18,7 +20,7 @@ vi.mock("../src/client.js", () => ({
const { proofCommand } = await import("../src/commands/proof.js");
const run = (args: string[]) => proofCommand().parseAsync(["node", "proof", ...args]);
beforeEach(() => { calls.length = 0; readExtra = {}; vi.spyOn(console, "log").mockImplementation(() => {}); });
beforeEach(() => { calls.length = 0; readExtra = {}; readFailure = null; vi.spyOn(console, "log").mockImplementation(() => {}); });
describe("rig proof --project (#132)", () => {
it("show passes --project and the scope as query parameters", async () => {
@@ -51,3 +53,41 @@ describe("rig proof --project (#132)", () => {
expect(calls[3]!.body).not.toHaveProperty("projectRoot");
});
});
describe("O2: a path_escape scope names the routes to another workspace", () => {
const escape = { status: 400, data: { error: "path_escape", message: "Path escapes /w/missions; paths must stay within the selected workspace" } };
const shown = async (args: string[]): Promise<string> => {
const errors: string[] = [];
vi.spyOn(console, "error").mockImplementation((text: unknown) => { errors.push(String(text)); });
readFailure = escape;
await run(args);
process.exitCode = undefined;
return errors.join("\n");
};
it("without a project, names --project, the <project>: prefix and OPENRIG_URL, not rig scope", async () => {
const text = await shown(["show", "/other/projects/alpha/missions/m1/slices/01-x"]);
expect(text).toContain("path_escape: Path escapes /w/missions");
expect(text).toContain("For a catalog project, pass --project <id> or prefix the scope with <project>:.");
expect(text).toContain("To use another daemon's workspace, select that daemon with OPENRIG_URL.");
expect(text).not.toContain("rig scope");
});
it("with --project or a <project>: prefix, says the scope is outside that project's tree", async () => {
for (const args of [["show", "/x/slices/01-x", "--project", "alpha"], ["judge", "alpha:/x/slices/01-x#1", "--verdict", "accept", "--reason", "r"]]) {
const text = await shown(args);
expect(text).toContain("The scope is outside that project's missions root");
expect(text).not.toContain("pass --project");
}
});
it("another daemon error keeps its message without the hint", async () => {
readFailure = null;
const errors: string[] = [];
vi.spyOn(console, "error").mockImplementation((text: unknown) => { errors.push(String(text)); });
readFailure = { status: 404, data: { error: "scope_missing", message: "Select an existing mission or slice directory" } };
await run(["show", "m0/slices/01-t001"]);
process.exitCode = undefined;
expect(errors.join("\n")).toBe("scope_missing: Select an existing mission or slice directory");
});
});
+23
View File
@@ -401,6 +401,29 @@ describe("OPR.0.4.0.25 — rig ps token-safe defaults", () => {
} finally { Object.assign(node, original); }
});
it.each([
// A served question on a seat that also carries a recorded startup error: the question's reason.
{ arbitrated: true, questionReason: "permission_prompt", expected: "permission_prompt" },
// A served question whose reason isn't served: never the recorded error.
{ arbitrated: true, questionReason: null, expected: "reason unavailable" },
// A served "no" with a stale needs_input hook: no question, so the recorded error leads as before.
{ arbitrated: false, questionReason: null, expected: "startup failure" },
])("REASON names a live question's reason over a recorded error, with the daemon's precedence (arbitrated: $arbitrated)", async ({ arbitrated, questionReason, expected }) => {
const node = RIG_B_NODES[0]!;
const original = { ...node };
Object.assign(node, { needsAttention: true, arbitratedNeedsInput: arbitrated, startupStatus: "failed", latestError: "startup failure",
activityState: { activity: "needs_input", display: "needs input", needsInput: { count: arbitrated ? 1 : 0, reason: questionReason }, decidedBy: "hook" },
agentActivity: { ...node.agentActivity, state: "needs_input", reason: "approval_pending" } });
try {
const text = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--rig", "openrig-comms"]).then(() => {}));
expect(text.logs.find((line) => line.includes(node.canonicalSessionName))).toMatch(new RegExp(`yes\\s+${expected}\\s*$`));
} finally {
// Restore the shared fixture exactly, including dropping keys it didn't have (activityState).
for (const key of Object.keys(node)) if (!(key in original)) Reflect.deleteProperty(node, key);
Object.assign(node, original);
}
});
it.each([[false, false], [true, false], [false, true]])("shows current limits without hiding startup failures (stale: %s, failed: %s)", async (stale, failed) => {
const node = RIG_B_NODES[0]!;
const original = { agentActivity: node.agentActivity, startupStatus: node.startupStatus, latestError: node.latestError, needsAttention: node.needsAttention };
@@ -1,6 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Hono } from "hono";
import { createDaemon } from "../../daemon/src/startup.js";
import { SettingsStore } from "../../daemon/src/domain/user-settings/settings-store.js";
import { queueRoutes } from "../../daemon/src/routes/queue.js";
import { getSelfHostId, setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { deriveCurrentWork, resolveWorkNodeDirs } from "../../daemon/src/domain/current-work.js";
import { queueCommand, type QueueDeps } from "../src/commands/queue.js";
import { DaemonResponseError } from "../src/client.js";
describe("queue destination advisory through CLI and route", () => {
it("prints the accepted typo unchanged for create and both handoffs", async () => {
@@ -56,3 +65,884 @@ describe("queue destination advisory through CLI and route", () => {
}
}, 30_000);
});
describe("queue create scope advisories preserve admission and output", () => {
type Daemon = Awaited<ReturnType<typeof createDaemon>>;
type Fixture = { daemon: Daemon; app: Hono; root: string; missions: string; hostId: string | null };
let scratch: string;
let daemons: Daemon[];
let source: Fixture;
let stdout: string[];
let stderr: string[];
let responseHeaders: Headers[];
let createResponses: Array<{ status: number; data: Record<string, unknown> }>;
let savedHost: string | null;
let savedExit: typeof process.exitCode;
function spec(directory: string, id: string): void {
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "SPEC.md"), `---\nid: ${id}\n---\n# Fixture\n`);
}
async function fixture(name: string): Promise<Fixture> {
const root = path.join(scratch, name);
const missions = path.join(root, "alpha", "missions");
spec(path.join(root, "alpha"), "alpha");
fs.writeFileSync(path.join(root, "workspace.yaml"), "projects:\n - id: alpha\n root: alpha\n");
fs.writeFileSync(path.join(root, "alpha", "project.yaml"), "id: alpha\nmissions:\n root: missions\n");
spec(path.join(missions, "release"), "MISSION.ONE");
spec(path.join(missions, "release", "slices", "build"), "SLICE.ONE");
for (const dir of ["duplicate-a", "duplicate-b"]) {
spec(path.join(missions, dir), "DUP.MISSION");
spec(path.join(missions, "release", "slices", dir), "DUP.SLICE");
}
const fallback = path.join(root, "fallback");
spec(path.join(fallback, "release"), "FALLBACK.MISSION");
spec(path.join(fallback, "release", "slices", "build"), "FALLBACK.SLICE");
const config = path.join(root, "settings.json");
fs.writeFileSync(config, JSON.stringify({ workspace: {
root, slicesRoot: fallback, catalogPath: path.join(root, "workspace.yaml"),
} }));
const daemon = await createDaemon({ dbPath: ":memory:", tmuxExec: async () => "",
cmuxFactory: async () => { throw Object.assign(new Error("no fixture socket"), { code: "ENOENT" }); } });
daemons.push(daemon);
const rig = daemon.deps.rigRepo.createRig("scope");
for (const member of ["writer", "worker"]) daemon.deps.rigRepo.addNode(rig.id, member, { runtime: null });
const app = new Hono();
app.use("*", async (c, next) => {
const set = c.set.bind(c) as (key: string, value: unknown) => void;
for (const key of ["eventBus", "queueRepo", "inboxHandler", "outboxHandler", "rigRepo"] as const) {
set(key, daemon.deps[key]);
}
set("settingsStore", new SettingsStore(config));
await next();
});
app.route("/api/queue", queueRoutes());
return { daemon, app, root, missions, hostId: getSelfHostId() };
}
beforeEach(async () => {
savedExit = process.exitCode;
savedHost = getSelfHostId();
process.exitCode = undefined;
daemons = [];
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "queue-scope-"));
vi.stubEnv("OPENRIG_NO_KERNEL", "1");
vi.stubEnv("OPENRIG_SESSION_NAME", "writer@scope");
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "0");
stdout = [];
stderr = [];
responseHeaders = [];
createResponses = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { stdout.push(args.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { stderr.push(args.join(" ")); });
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { stderr.push(String(chunk)); return true; });
source = await fixture("sender");
});
afterEach(() => {
for (const daemon of daemons) {
daemon.eventLoopMonitor.stop();
daemon.contextMonitor.stop();
daemon.deps.seatActivityService?.stop();
daemon.db.close();
}
setSelfHostId(savedHost);
vi.restoreAllMocks();
vi.unstubAllEnvs();
process.exitCode = savedExit;
fs.rmSync(scratch, { recursive: true, force: true });
});
async function create(app: Hono, args: string[], json: boolean, id = "qitem-create-contract", clientFactory?: QueueDeps["clientFactory"], destination = "worker@scope", jsonOnlyResponse = false): Promise<void> {
stdout.length = 0;
stderr.length = 0;
const deps: QueueDeps = {
lifecycleDeps: { readFile: () => null, exists: () => false,
fetch: async () => ({ ok: true }) } as QueueDeps["lifecycleDeps"],
clientFactory: clientFactory ?? (() => ({
post: async (url: string, body: unknown) => {
const response = await app.request(url, { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "writer@scope" },
body: JSON.stringify(body) });
responseHeaders.push(response.headers);
const data = await response.json() as Record<string, unknown>;
createResponses.push({ status: response.status, data });
return { status: response.status, data, ...(jsonOnlyResponse ? {} : { headers: response.headers }) };
},
}) as unknown as ReturnType<QueueDeps["clientFactory"]>),
};
await queueCommand(deps).parseAsync(["create", "--destination", destination,
"--body", "scope contract body", "--summary", "scope contract", "--id", id,
"--no-nudge", "--full", ...args, ...(json ? ["--json"] : [])], { from: "user" });
}
function committed(f: Fixture, id: string, tags: string[] | null): void {
const stored = f.daemon.deps.queueRepo.getById(id);
expect(stored).toMatchObject({ qitemId: id, destinationSession: "worker@scope",
body: "scope contract body", summary: "scope contract", state: "pending", priority: "routine", tags });
expect(f.daemon.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE qitem_id = ?").get(id)).toEqual({ n: 1 });
expect(process.exitCode ?? 0).toBe(0);
expect(stdout).toHaveLength(1);
// The independent read pins the unchanged primary row; advisories are the additive response field.
const output = JSON.parse(stdout[0]!) as Record<string, unknown>;
const { advisories, ...primary } = output;
expect(primary).toEqual(JSON.parse(JSON.stringify(stored)));
expect(advisories === undefined || Array.isArray(advisories)).toBe(true);
}
const scopeLines = () => stderr.join("\n").split("\n")
.filter(line => /\bscope\b|mission:|slice:/i.test(line)).join("\n");
function assertJsonCreate(f: Fixture, destination: string, tags: string[]): Record<string, unknown> {
expect(createResponses).toHaveLength(1);
expect(createResponses[0]!.status).toBe(201);
const data = createResponses[0]!.data;
const stored = f.daemon.deps.queueRepo.getById("qitem-create-contract");
expect(stored).toMatchObject({ destinationSession: destination, body: "scope contract body", tags });
expect(f.daemon.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE qitem_id = ?")
.get("qitem-create-contract")).toEqual({ n: 1 });
const { advisories: _advisories, ...primary } = data;
expect(primary).toEqual(JSON.parse(JSON.stringify(stored)));
expect(stdout).toHaveLength(1);
expect(JSON.parse(stdout[0]!)).toEqual(data);
expect(process.exitCode ?? 0).toBe(0);
return data;
}
it.each([
{ mission: "MISSING.MISSION", code: "unresolved_scope", diagnostic: /unresolved|resolves to 0/i },
{ mission: "DUP.MISSION", code: "ambiguous_scope", diagnostic: /ambiguous|resolves to 2/i },
{ mission: "release", code: "scope_check_unavailable", diagnostic: /unavailable|could not.*check/i },
])("carries $code in the real create JSON and renders it once without response headers", async row => {
if (row.code === "scope_check_unavailable") {
fs.writeFileSync(path.join(source.root, "workspace.yaml"), "projects: invalid\n");
}
const unchanged = preserveAuthored(source);
const tag = "mission:" + row.mission;
await create(source.app, ["--mission", row.mission, "--tags", "project:alpha"], true,
"qitem-create-contract", undefined, "worker@scope", true);
const data = assertJsonCreate(source, "worker@scope", [tag, "project:alpha"]);
unchanged();
expect.soft(data.advisories).toEqual([expect.objectContaining({ code: row.code,
message: expect.stringMatching(row.diagnostic) })]);
const advice = Array.isArray(data.advisories) ? data.advisories : [];
expect.soft(advice.map(item => item.message).join("\n")).toContain(tag);
expect.soft(responseHeaders[0]!.has("X-OpenRig-Scope-Advisory")).toBe(false);
const rendered = scopeLines().split("\n").filter(line => line.includes(tag) && row.diagnostic.test(line));
expect.soft(rendered).toHaveLength(1);
});
it("keeps the destination advisory before scope advice in the real JSON response", async () => {
const destination = "missing-worker@scope";
await create(source.app, ["--mission", "MISSING.MISSION", "--tags", "project:alpha"], true,
"qitem-create-contract", undefined, destination, true);
const data = assertJsonCreate(source, destination, ["mission:MISSING.MISSION", "project:alpha"]);
const advice = Array.isArray(data.advisories) ? data.advisories : [];
expect.soft(advice.map(item => item.code)).toEqual(["unmatched_destination_seat", "unresolved_scope"]);
expect.soft(advice[0]).toMatchObject({ destinationSession: destination,
availableDestinations: expect.arrayContaining(["worker@scope", "writer@scope"]) });
expect.soft(scopeLines().split("\n").filter(line => /mission:MISSING.MISSION.*unresolved|unresolved.*mission:MISSING.MISSION/i.test(line)))
.toHaveLength(1);
});
it("keeps long Unicode scope messages in JSON and stderr without a header-sized limit", async () => {
fs.writeFileSync(path.join(source.root, "workspace.yaml"), "projects: invalid\n");
const tags = ["project:alpha", ...Array.from({ length: 9 }, (_, i) =>
"mission:" + ("部署_" + i + "_").padEnd(150, "x") + "_末尾")];
await create(source.app, ["--tags", tags.join(",")], true,
"qitem-create-contract", undefined, "worker@scope", true);
const data = assertJsonCreate(source, "worker@scope", tags);
const advice = Array.isArray(data.advisories) ? data.advisories : [];
const messages = advice.map(item => item.message).join("\n");
expect.soft(advice).toEqual([expect.objectContaining({ code: "scope_check_unavailable", message: expect.any(String) })]);
expect.soft(messages.length).toBeGreaterThan(1024);
for (const tag of tags.slice(1)) {
expect.soft(messages).toContain(tag);
expect.soft(scopeLines()).toContain(tag);
}
expect.soft(scopeLines().split("\n").filter(line => /unavailable|could not.*check/i.test(line))).toHaveLength(1);
expect.soft(responseHeaders[0]!.has("X-OpenRig-Scope-Advisory")).toBe(false);
});
it("returns skipped scope advice for a forwarded create without sender scope reads", async () => {
const receiver = await fixture("json-receiver");
const { app, observation } = forwardingApp(receiver);
const senderReads = observeSenderScopeReads();
setSelfHostId(source.hostId);
await create(app, ["--host", "remote-fixture", "--mission", "MISSING.MISSION", "--tags", "project:alpha"], true,
"qitem-create-contract", undefined, "worker@scope", true);
const data = assertJsonCreate(receiver, "worker@scope", ["mission:MISSING.MISSION", "project:alpha", "cross-host", "from-host:" + os.hostname()]);
expect(observation.forwards).toBe(1);
expect(source.daemon.deps.queueRepo.getById("qitem-create-contract")).toBeNull();
expect(senderReads).toEqual([]);
const advice = Array.isArray(data.advisories) ? data.advisories : [];
expect.soft(advice).toEqual([expect.objectContaining({ code: expect.any(String), message: expect.stringMatching(/skipped.*cross-host|cross-host.*skipped/i) })]);
expect.soft(scopeLines()).toMatch(/skipped/i);
expect.soft(scopeLines()).not.toMatch(/unresolved|unavailable|resolves to 0/i);
expect.soft(responseHeaders[0]!.has("X-OpenRig-Scope-Advisory")).toBe(false);
});
it("keeps receiver destination advice before forwarded skipped scope advice", async () => {
const receiver = await fixture("advisory-receiver");
const { app, observation } = forwardingApp(receiver);
const senderReads = observeSenderScopeReads();
setSelfHostId(source.hostId);
const destination = "missing-worker@scope";
const tags = ["mission:release", "slice:SLICE.ONE", "project:alpha", "cross-host", "from-host:" + os.hostname()];
await create(app, ["--host", "remote-fixture", "--mission", "release", "--slice", "SLICE.ONE",
"--tags", "project:alpha"], true, "qitem-create-contract", undefined, destination);
const data = assertJsonCreate(receiver, destination, tags);
const advisories = Array.isArray(data.advisories)
? data.advisories as Array<{ code?: unknown; message?: unknown }>
: [];
expect(advisories.map(advisory => advisory.code)).toEqual(["unmatched_destination_seat", "scope_check_skipped"]);
expect(advisories[0]).toMatchObject({ destinationSession: destination,
availableDestinations: expect.arrayContaining(["worker@scope", "writer@scope"]) });
expect(advisories[1]).toMatchObject({ message: expect.stringMatching(/skipped.*cross-host|cross-host.*skipped/i) });
expect(observation.forwards).toBe(1);
expect(observation.committedStatuses).toEqual([201]);
expect(source.daemon.deps.queueRepo.getById("qitem-create-contract")).toBeNull();
expect(receiver.daemon.deps.queueRepo.getById("qitem-create-contract")?.sourceSession)
.toBe("writer@scope@" + source.hostId);
expect(senderReads).toEqual([]);
});
it.each([
{ label: "JSON null", reply: () => Response.json(null, { status: 202 }), expectedBody: "null" },
{ label: "JSON array", reply: () => Response.json(["receiver-payload"], { status: 202 }), expectedBody: '["receiver-payload"]' },
{ label: "non-JSON body", reply: () => new Response("receiver-payload", {
status: 202, headers: { "Content-Type": "text/plain" },
}), expectedBody: "" },
])("passes through a forwarded $label response unchanged", async row => {
const receiver = await fixture("malformed-response-receiver");
const { app, observation } = forwardingApp(receiver, false, row.reply);
const senderReads = observeSenderScopeReads();
setSelfHostId(source.hostId);
const id = "qitem-create-contract";
const response = await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "writer@scope" },
body: JSON.stringify({ qitemId: id, destinationSession: "worker@scope", body: "scope contract body",
summary: "scope contract", nudge: false, hostId: "remote-fixture",
tags: ["mission:release", "slice:SLICE.ONE", "project:alpha"] }),
});
const responseBody = await response.text();
expect(response.status).toBe(202);
expect(responseBody).toBe(row.expectedBody);
expect(observation.forwards).toBe(1);
expect(observation.committedStatuses).toEqual([202]);
expect(source.daemon.deps.queueRepo.getById(id)).toBeNull();
expect(receiver.daemon.deps.queueRepo.getById(id)).toBeNull();
expect(senderReads).toEqual([]);
});
function preserveAuthored(f: Fixture): () => void {
const read = fs.readFileSync.bind(fs);
const stat = fs.statSync.bind(fs);
const list = fs.readdirSync.bind(fs);
const snapshot = (): Record<string, string> => {
const files: Record<string, string> = {};
const visit = (file: string): void => {
if (stat(file).isDirectory()) {
for (const child of list(file).sort()) visit(path.join(file, child));
} else files[path.relative(f.root, file)] = read(file, "utf8");
};
for (const file of ["workspace.yaml", "alpha/project.yaml", "alpha/missions"]) visit(path.join(f.root, file));
return files;
};
const before = snapshot();
return () => expect(snapshot()).toEqual(before);
}
function observeSenderScopeReads(): string[] {
const reads: string[] = [];
for (const method of ["readdirSync", "readFileSync", "statSync", "existsSync"] as const) {
const original = fs[method].bind(fs) as (...args: unknown[]) => unknown;
vi.spyOn(fs, method).mockImplementation(((...args: unknown[]) => {
if (String(args[0]) === path.join(source.root, "workspace.yaml")
|| String(args[0]) === source.missions || String(args[0]).startsWith(source.missions + path.sep)) {
reads.push(method + ":" + String(args[0]));
}
return original(...args);
}) as never);
}
return reads;
}
function forwardingApp(receiver: Fixture, loseResponse = false, remoteResponse?: () => Response) {
const observation = { forwards: 0, committedStatuses: [] as number[] };
const app = new Hono();
app.use("*", async (c, next) => {
const set = c.set.bind(c) as (key: string, value: unknown) => void;
for (const key of ["eventBus", "queueRepo", "inboxHandler", "outboxHandler", "rigRepo"] as const) set(key, source.daemon.deps[key]);
set("settingsStore", new SettingsStore(path.join(source.root, "settings.json")));
set("hostRegistryLoader", () => ({ ok: true, registry: { hosts: [
{ id: "remote-fixture", transport: "http", url: "http://remote-fixture.invalid:39001" },
] } }));
set("remoteFetchImpl", async (_url: unknown, init: RequestInit) => {
observation.forwards++;
const body = JSON.parse(String(init.body));
const previous = getSelfHostId();
setSelfHostId(receiver.hostId);
try {
const response = remoteResponse
? remoteResponse()
: await receiver.app.request("/api/queue/create", { ...init,
headers: { "Content-Type": "application/json", "X-OpenRig-Session": body.sourceSession } });
observation.committedStatuses.push(response.status);
if (loseResponse) {
await response.text();
return new Response(new ReadableStream({ start(controller) {
controller.error(new Error("fixture lost receiver body after commit"));
} }), { status: response.status, headers: response.headers });
}
return response;
} finally { setSelfHostId(previous); }
});
await next();
});
app.route("/api/queue", queueRoutes());
return { app, observation };
}
function recoveredClient(app: Hono, target: Fixture, typedLoss: boolean, readHealth?: (observation: number) => string | null) {
const calls = { posts: [] as string[], readbacks: [] as Array<{ base: string; url: string }>, postStatuses: [] as number[],
postBodies: [] as unknown[], committedBeforeLoss: [] as unknown[], healthReads: 0,
healthResults: [] as Array<string | null> };
const factory: QueueDeps["clientFactory"] = (base: string) => ({
senderIdentity: async () => ({ session: "writer@scope", originUnknown: false }),
post: async (url: string, body: unknown) => {
calls.posts.push(url);
calls.postBodies.push(body);
const response = await app.request(url, { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "writer@scope" },
body: JSON.stringify(body) });
calls.postStatuses.push(response.status);
if (typedLoss) {
await response.text();
calls.committedBeforeLoss.push(target.daemon.deps.queueRepo.getById((body as { qitemId: string }).qitemId));
throw new DaemonResponseError(response.status, "fixture lost committed response body");
}
return { status: response.status, data: await response.json(), headers: response.headers };
},
get: async (url: string) => {
if (url === "/healthz") {
calls.healthReads++;
try {
const selfHostId = readHealth ? readHealth(calls.healthReads) : source.hostId;
calls.healthResults.push(selfHostId);
return { status: 200, data: { selfHostId } };
} catch (error) {
calls.healthResults.push("unreadable");
throw error;
}
}
calls.readbacks.push({ base, url });
const row = target.daemon.deps.queueRepo.getById(decodeURIComponent(url.slice("/api/queue/".length)));
return row ? { status: 200, data: row } : { status: 404, data: { error: "not_found" } };
},
}) as unknown as ReturnType<QueueDeps["clientFactory"]>;
return { factory, calls };
}
it.each(["SLICE.ONE", "MISSING.SLICE"])("reports slice-only %s as unavailable without mission context", async slice => {
const unchanged = preserveAuthored(source);
await create(source.app, ["--slice", slice, "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", ["slice:" + slice, "project:alpha"]);
unchanged();
expect.soft(scopeLines()).toContain("slice:" + slice);
expect.soft(scopeLines()).toMatch(/\bunavailable\b/i);
expect.soft(scopeLines()).toMatch(/\bmission\b/i);
expect.soft(scopeLines()).toMatch(/absent|missing|need|requir|without|not supplied/i);
expect.soft(scopeLines()).not.toMatch(/resolves to 0|checked.*unresolved|not found/i);
});
it.each([
{ label: "mission alone", tags: ["mission:"], empty: "mission:", valid: null },
{ label: "slice alone", tags: ["slice:"], empty: "slice:", valid: null },
{ label: "mission beside valid slice", tags: ["mission:", "slice:SLICE.ONE"], empty: "mission:", valid: "slice:SLICE.ONE" },
{ label: "slice beside valid mission", tags: ["mission:release", "slice:"], empty: "slice:", valid: "mission:release" },
])("identifies the empty value for $label without blaming the other tag", async row => {
const unchanged = preserveAuthored(source);
const tags = [...row.tags, "project:alpha"];
await create(source.app, ["--tags", tags.join(",")], true);
committed(source, "qitem-create-contract", tags);
unchanged();
expect.soft(scopeLines()).toContain(row.empty);
expect.soft(scopeLines()).toMatch(/\bempty\b/i);
expect.soft(scopeLines()).not.toMatch(/resolves to 0|checked.*unresolved/i);
if (row.valid) expect.soft(scopeLines()).not.toContain(row.valid + " is unresolved");
});
it.each(["mission:", "slice:"])("keeps remote empty-only %s SKIPPED without sender scope reads", async empty => {
const receiver = await fixture("receiver");
const sourceUnchanged = preserveAuthored(source);
const receiverUnchanged = preserveAuthored(receiver);
const { app, observation } = forwardingApp(receiver);
const senderReads = observeSenderScopeReads();
setSelfHostId(source.hostId);
await create(app, ["--host", "remote-fixture", "--tags", empty + ",project:alpha"], true);
const tags = [empty, "project:alpha", "cross-host", "from-host:" + os.hostname()];
committed(receiver, "qitem-create-contract", tags);
expect(source.daemon.deps.queueRepo.getById("qitem-create-contract")).toBeNull();
expect(receiver.daemon.deps.queueRepo.getById("qitem-create-contract")?.sourceSession).toBe("writer@scope@" + source.hostId);
expect(observation.forwards).toBe(1);
expect(observation.committedStatuses).toEqual([201]);
expect(senderReads).toEqual([]);
sourceUnchanged();
receiverUnchanged();
expect.soft(scopeLines()).toMatch(/\bskipped\b/i);
expect.soft(scopeLines()).toContain(empty);
expect.soft(scopeLines()).not.toMatch(/unresolved|unavailable|resolves to 0/i);
});
it.each(["mission", "slice"] as const)("checks an exact-name regular file at %s level as unresolved", async level => {
const parent = level === "mission" ? source.missions : path.join(source.missions, "release", "slices");
const wanted = level === "mission" ? "release" : "build";
const target = path.join(parent, wanted);
fs.rmSync(target, { recursive: true });
fs.writeFileSync(target, "a regular file is not a work directory");
expect(fs.statSync(target).isFile()).toBe(true);
// Historical current-work resolution stays permissive; this advisory has a stricter directory contract.
expect(resolveWorkNodeDirs(parent, wanted)).toEqual([{ dir: wanted, form: "directory name" }]);
const unchanged = preserveAuthored(source);
const args = level === "mission" ? ["--mission", wanted] : ["--mission", "release", "--slice", wanted];
const tags = level === "mission" ? ["mission:release", "project:alpha"] : ["mission:release", "slice:build", "project:alpha"];
await create(source.app, [...args, "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", tags);
unchanged();
expect.soft(scopeLines()).toContain(level + ":" + wanted);
expect.soft(scopeLines()).toMatch(/\bunresolved\b/i);
expect.soft(scopeLines()).not.toMatch(/unavailable|skipped/i);
});
it.each([true, false])("reconciles a real local commit once with scope-tagged=%s", async tagged => {
const unchanged = preserveAuthored(source);
const { factory, calls } = recoveredClient(source.app, source, true);
const args = tagged ? ["--mission", "missing-mission", "--tags", "project:alpha"] : [];
await create(source.app, args, true, "qitem-create-contract", factory);
committed(source, "qitem-create-contract", tagged ? ["mission:missing-mission", "project:alpha"] : null);
expect(calls.posts).toEqual(["/api/queue/create"]);
expect(calls.postStatuses).toEqual([201]);
expect(calls.readbacks.map(call => call.url)).toEqual(["/api/queue/qitem-create-contract"]);
expect(stderr.join("\n")).toContain("committed after all: row qitem-create-contract");
unchanged();
if (tagged) {
expect.soft(scopeLines()).toContain("mission:missing-mission");
expect.soft(scopeLines()).toMatch(/\bunavailable\b/i);
expect.soft(scopeLines()).toMatch(/\boutcome\b|\bresult\b/i);
expect.soft(scopeLines()).not.toMatch(/resolves to 0|checked.*unresolved/i);
} else expect(scopeLines()).toBe("");
});
async function recoverOwnHost(form: "--host" | "qualified destination", readHealth?: (observation: number) => string | null) {
expect(source.hostId).toEqual(expect.any(String));
expect(source.hostId).not.toBe("local");
expect(getSelfHostId()).toBe(source.hostId);
const health = await source.daemon.app.request("/healthz");
expect(health.status).toBe(200);
expect(await health.json()).toMatchObject({ selfHostId: source.hostId });
const unchanged = preserveAuthored(source);
const { factory, calls } = recoveredClient(source.app, source, true, readHealth);
const args = ["--mission", "missing-mission", "--tags", "project:alpha"];
if (form === "--host") args.push("--host", source.hostId!);
const destination = form === "qualified destination" ? "worker@scope@" + source.hostId : "worker@scope";
await create(source.app, args, true, "qitem-create-contract", factory, destination);
const tags = ["mission:missing-mission", "project:alpha"];
committed(source, "qitem-create-contract", tags);
const stored = source.daemon.deps.queueRepo.getById("qitem-create-contract");
expect(stored?.sourceSession).toBe("writer@scope");
expect(stored?.destinationSession).toBe("worker@scope");
expect(stored?.tags).toEqual(tags);
expect(source.daemon.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE body = ?").get("scope contract body")).toEqual({ n: 1 });
expect(calls.posts).toEqual(["/api/queue/create"]);
expect(calls.postStatuses).toEqual([201]);
expect(calls.postBodies).toEqual([expect.objectContaining({ hostId: source.hostId,
destinationSession: "worker@scope", qitemId: "qitem-create-contract", tags })]);
expect(calls.committedBeforeLoss).toEqual([stored]);
expect(calls.healthReads).toBeGreaterThan(0);
expect(calls.readbacks.map(call => call.url)).toEqual(["/api/queue/qitem-create-contract"]);
expect(stderr.join("\n")).toContain("committed after all: row qitem-create-contract");
unchanged();
return calls;
}
it.each(["--host", "qualified destination"] as const)("keeps recovered own-host %s advice local after a committed response is lost", async form => {
await recoverOwnHost(form);
expect.soft(scopeLines()).toContain("mission:missing-mission");
expect.soft(scopeLines()).toMatch(/\bunavailable\b/i);
expect.soft(scopeLines()).toMatch(/\boutcome\b|\bresult\b/i);
expect.soft(scopeLines()).toMatch(/response recovery/i);
expect.soft(scopeLines()).not.toMatch(/\bskipped\b|cross-host|\bforwarded\b/i);
});
it.each(["--host", "qualified destination"] as const)(
"keeps own-host %s recovery advice unknown when the first identity read fails but the later match succeeds",
async form => {
const registryUrl = "http://own-host-recovery.invalid:39001";
const home = path.join(scratch, "cli-home-own-host-recovery");
fs.mkdirSync(home);
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: ${source.hostId}\n transport: http\n url: ${registryUrl}\n`);
vi.stubEnv("OPENRIG_HOME", home);
const calls = await recoverOwnHost(form, observation => {
if (observation === 1) throw new Error("fixture first identity read is unavailable");
return source.hostId;
});
expect(calls.healthResults).toEqual(["unreadable", source.hostId]);
expect(calls.healthReads).toBe(2);
expect(calls.readbacks).toEqual([{
base: registryUrl,
url: "/api/queue/qitem-create-contract",
}]);
expect(scopeLines()).toContain("mission:missing-mission");
expect(scopeLines()).toMatch(/scope check outcome unavailable after response recovery/i);
expect(scopeLines()).not.toMatch(/\bskipped\b|cross-host|\bforwarded\b/i);
},
);
function healthAfterMatchedRecovery(later: "different" | "null" | "unreadable") {
return (observation: number): string | null => {
// The target and source checks see the real host. Only the later advice read changes.
if (observation <= 2) return source.hostId;
if (later === "unreadable") throw new Error("fixture health became unreadable after matching recovery");
return later === "null" ? null : "replacement-fixture";
};
}
it.each((["--host", "qualified destination"] as const).flatMap(form =>
(["different", "null", "unreadable"] as const).map(later => ({ form, later }))))(
"keeps matched own-host $form recovery local when later health is $later", async ({ form, later }) => {
const calls = await recoverOwnHost(form, healthAfterMatchedRecovery(later));
const evidence = "observed health sequence: " + JSON.stringify(calls.healthResults);
expect.soft(scopeLines(), evidence).toContain("mission:missing-mission");
expect.soft(scopeLines(), evidence).toMatch(/\bunavailable\b/i);
expect.soft(scopeLines(), evidence).toMatch(/\boutcome\b|\bresult\b/i);
expect.soft(scopeLines(), evidence).toMatch(/response recovery/i);
expect.soft(scopeLines(), evidence).not.toMatch(/\bskipped\b|cross-host|\bforwarded\b/i);
},
);
it.each(["--host", "qualified destination"] as const)(
"keeps own-host %s outcome unknown when health becomes unreadable before source matching", async form => {
expect(source.hostId).toEqual(expect.any(String));
expect(getSelfHostId()).toBe(source.hostId);
const unchanged = preserveAuthored(source);
const { factory, calls } = recoveredClient(source.app, source, true, observation => {
if (observation === 1) return source.hostId;
throw new Error("fixture health unreadable while matching the stored source");
});
const args = ["--mission", "missing-mission", "--tags", "project:alpha"];
if (form === "--host") args.push("--host", source.hostId!);
const destination = form === "qualified destination" ? "worker@scope@" + source.hostId : "worker@scope";
await expect(create(source.app, args, true, "qitem-create-contract", factory, destination))
.rejects.toMatchObject({ name: "DaemonResponseError", status: 201 });
const stored = source.daemon.deps.queueRepo.getById("qitem-create-contract");
expect(stored).toMatchObject({ qitemId: "qitem-create-contract", sourceSession: "writer@scope",
destinationSession: "worker@scope", body: "scope contract body", summary: "scope contract", state: "pending",
tags: ["mission:missing-mission", "project:alpha"] });
expect(source.daemon.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE body = ?")
.get("scope contract body")).toEqual({ n: 1 });
expect(calls.committedBeforeLoss).toEqual([stored]);
expect(calls.posts).toEqual(["/api/queue/create"]);
expect(calls.postStatuses).toEqual([201]);
expect(calls.readbacks.map(call => call.url)).toEqual(["/api/queue/qitem-create-contract"]);
expect(stdout).toEqual([]);
expect(stderr.join("\n")).toContain("couldn't read this daemon's host id to tell whether its source is this create's");
expect(stderr.join("\n")).toContain("outcome is still unknown");
expect(stderr.join("\n")).not.toContain("committed after all");
expect(scopeLines()).not.toMatch(/scope check|\bforwarded\b/i);
unchanged();
},
);
async function recoverForwardedCommit(readHealth?: (observation: number) => string | null) {
const receiver = await fixture("receiver");
const sourceUnchanged = preserveAuthored(source);
const receiverUnchanged = preserveAuthored(receiver);
const { app, observation } = forwardingApp(receiver, true);
const { factory, calls } = recoveredClient(app, receiver, false, readHealth);
const home = path.join(scratch, "cli-home");
fs.mkdirSync(home);
fs.writeFileSync(path.join(home, "hosts.yaml"), "hosts:\n - id: remote-fixture\n transport: http\n url: http://remote-fixture.invalid:39001\n");
vi.stubEnv("OPENRIG_HOME", home);
const senderReads = observeSenderScopeReads();
setSelfHostId(source.hostId);
await create(app, ["--host", "remote-fixture", "--mission", "release", "--slice", "SLICE.ONE", "--tags", "project:alpha"], true,
"qitem-create-contract", factory);
committed(receiver, "qitem-create-contract", ["mission:release", "slice:SLICE.ONE", "project:alpha", "cross-host", "from-host:" + os.hostname()]);
expect(source.daemon.deps.queueRepo.getById("qitem-create-contract")).toBeNull();
expect(receiver.daemon.deps.queueRepo.getById("qitem-create-contract")?.sourceSession).toBe("writer@scope@" + source.hostId);
expect(calls.posts).toEqual(["/api/queue/create"]);
expect(calls.postStatuses).toEqual([502]);
expect(observation.forwards).toBe(1);
expect(observation.committedStatuses).toEqual([201]);
expect(calls.readbacks).toEqual([{ base: "http://remote-fixture.invalid:39001", url: "/api/queue/qitem-create-contract" }]);
expect(stderr.join("\n")).toContain("committed after all: row qitem-create-contract on host 'remote-fixture'");
expect(senderReads).toEqual([]);
sourceUnchanged();
receiverUnchanged();
expect.soft(scopeLines()).toMatch(/\bskipped\b/i);
expect.soft(scopeLines()).toContain("mission:release");
expect.soft(scopeLines()).toContain("slice:SLICE.ONE");
expect.soft(scopeLines()).not.toMatch(/unavailable|unresolved|resolves to 0/i);
}
it("reconciles a forwarded indeterminate commit with SKIPPED and destination provenance", async () => {
await recoverForwardedCommit();
});
it.each(["null", "unreadable"] as const)(
"keeps confirmed remote recovery SKIPPED when later health is %s", async later => {
await recoverForwardedCommit(healthAfterMatchedRecovery(later));
},
);
it.each([
{ level: "mission", tags: ["mission:release", "mission:MISSION.ONE", "slice:SLICE.ONE", "project:alpha"] },
{ level: "slice", tags: ["mission:release", "slice:SLICE.ONE", "slice:build", "project:alpha"] },
])("reports distinct $level inputs as a conflict, not a directory count", async row => {
const unchanged = preserveAuthored(source);
const old = deriveCurrentWork([{ qitemId: "input-conflict", state: "in-progress", tags: row.tags }], source.missions);
expect(old.currentWork).toBeNull();
expect(old.currentWorkBasis).toContain("conflicting typed tags:");
expect(old.currentWorkBasis).toContain("2 distinct " + row.level + " tags");
await create(source.app, ["--tags", row.tags.join(",")], true);
committed(source, "qitem-create-contract", row.tags);
unchanged();
expect.soft(scopeLines()).toMatch(/\bconflict(?:ing)?\b/i);
expect.soft(scopeLines()).toContain(row.level + ":");
expect.soft(scopeLines()).not.toMatch(/resolves to \d+ directories/i);
});
it.each(["mission", "slice"] as const)("retains a visible %s conflict when long values share a display prefix", async level => {
const common = ("ordinary_" + level + "_").padEnd(220, "x");
const first = level + ":" + common + "A";
const second = level + ":" + common + "B";
const tags = level === "mission"
? [first, second, "project:alpha"] : ["mission:release", first, second, "project:alpha"];
const unchanged = preserveAuthored(source);
await create(source.app, ["--tags", tags.join(",")], true);
committed(source, "qitem-create-contract", tags);
unchanged();
const advisories = createResponses[0]?.data.advisories as Array<{ code?: unknown; message?: unknown }> | undefined;
expect(advisories).toHaveLength(1);
expect(advisories?.[0]).toMatchObject({ code: "conflicting_scope", message: expect.any(String) });
const message = advisories?.[0]?.message as string;
expect(message).toContain(first);
expect(message).toContain(second);
expect.soft(scopeLines()).toMatch(/\bconflict(?:ing)?\b/i);
expect.soft(scopeLines()).toContain(level + ":");
expect.soft(scopeLines()).toContain(first);
expect.soft(scopeLines()).toContain(second);
expect.soft(scopeLines()).not.toMatch(/resolves to \d+ directories/i);
});
it.each([
{ level: "mission", code: "EACCES", wanted: "DUP.MISSION" },
{ level: "slice", code: "EIO", wanted: "DUP.SLICE" },
])("reports unavailable for two $level matches plus unreadable extra SPEC ($code)", async row => {
const lookup = row.level === "mission" ? source.missions : path.join(source.missions, "release", "slices");
for (const child of ["duplicate-a", "duplicate-b"]) {
expect(fs.statSync(path.join(lookup, child)).isDirectory()).toBe(true);
expect(fs.readFileSync(path.join(lookup, child, "SPEC.md"), "utf8")).toContain("id: " + row.wanted);
}
const extra = path.join(lookup, "unreadable-extra");
spec(extra, "EXTRA");
const file = path.join(extra, "SPEC.md");
expect(fs.statSync(file).isFile()).toBe(true);
const unchanged = preserveAuthored(source);
const read = fs.readFileSync.bind(fs);
let faults = 0;
vi.spyOn(fs, "readFileSync").mockImplementation(((...args: unknown[]) => {
if (String(args[0]) === file) {
faults++;
throw Object.assign(new Error("fixture extra SPEC read failed"), { code: row.code });
}
return (read as (...input: unknown[]) => unknown)(...args);
}) as typeof fs.readFileSync);
const args = row.level === "mission" ? ["--mission", row.wanted] : ["--mission", "release", "--slice", row.wanted];
const tags = row.level === "mission"
? ["mission:DUP.MISSION", "project:alpha"] : ["mission:release", "slice:DUP.SLICE", "project:alpha"];
await create(source.app, [...args, "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", tags);
expect(faults).toBeGreaterThan(0);
unchanged();
expect.soft(scopeLines()).toContain(row.level + ":" + row.wanted);
expect.soft(scopeLines()).toMatch(/\bunavailable\b/i);
expect.soft(scopeLines()).not.toMatch(/resolves to \d+ directories/i);
});
it.each([false, true])("keeps valid project scope successful with unchanged JSON (json=%s)", async json => {
await create(source.app, ["--mission", "release", "--slice", "SLICE.ONE", "--tags", "project:alpha"], json);
committed(source, "qitem-create-contract", ["mission:release", "slice:SLICE.ONE", "project:alpha"]);
expect(scopeLines()).not.toMatch(/unresolved|ambiguous|unavailable|could not|skipped/i);
});
it.each([false, true])("keeps untagged create unchanged (json=%s)", async json => {
await create(source.app, [], json);
committed(source, "qitem-create-contract", null);
expect(scopeLines()).toBe("");
});
it.each([
{ label: "missing mission", mission: "missing-mission", slice: "SLICE.ONE", tag: "mission:missing-mission", kind: /unresolved|not found|does not (?:exist|resolve)|resolves to 0|cannot.*uniquely/i },
{ label: "ambiguous mission", mission: "DUP.MISSION", slice: "SLICE.ONE", tag: "mission:DUP.MISSION", kind: /ambiguous|more than one|resolves to 2|not.*uniquely|cannot.*uniquely/i },
{ label: "missing slice", mission: "release", slice: "MISSING.SLICE", tag: "slice:MISSING.SLICE", kind: /unresolved|not found|does not (?:exist|resolve)|resolves to 0|cannot.*uniquely/i },
{ label: "ambiguous slice", mission: "release", slice: "DUP.SLICE", tag: "slice:DUP.SLICE", kind: /ambiguous|more than one|resolves to 2|not.*uniquely|cannot.*uniquely/i },
{ label: "completed empty-directory check", mission: "missing-mission", slice: "SLICE.ONE", tag: "mission:missing-mission", kind: /unresolved|not found|does not (?:exist|resolve)|resolves to 0|cannot.*uniquely/i },
])("commits $label and reports checked nonunique scope only on stderr", async row => {
if (row.label === "completed empty-directory check") {
fs.rmSync(source.missions, { recursive: true });
fs.mkdirSync(source.missions);
}
await create(source.app, ["--mission", row.mission, "--slice", row.slice, "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", [`mission:${row.mission}`, `slice:${row.slice}`, "project:alpha"]);
expect(scopeLines()).toContain(row.tag);
expect(scopeLines()).toMatch(row.kind);
expect(scopeLines()).not.toMatch(/unavailable|could not (?:run|check)|skipped/i);
});
it.each(["non-directory missions root", "invalid catalogue"])(
"commits when the check cannot run: %s, without calling it checked unresolved", async cause => {
if (cause === "non-directory missions root") {
fs.rmSync(source.missions, { recursive: true });
fs.writeFileSync(source.missions, "not a directory");
} else {
fs.writeFileSync(path.join(source.root, "workspace.yaml"), "projects: invalid\n");
}
await create(source.app, ["--mission", "release", "--slice", "SLICE.ONE", "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", ["mission:release", "slice:SLICE.ONE", "project:alpha"]);
expect(scopeLines()).toMatch(/unavailable|could not (?:run|check)|unable to (?:run|check)/i);
expect(scopeLines()).not.toMatch(/resolves to 0|not found|checked.*unresolved/i);
},
);
const longScopeTags = () => [
...Array.from({ length: 7 }, (_, i) => "mission:" + ("ordinary_mission_" + i + "_").padEnd(150, "x")),
...Array.from({ length: 2 }, (_, i) => "slice:" + ("ordinary_slice_" + i + "_").padEnd(150, "x")),
];
it.each([
{ level: "mission", code: "EACCES", mission: "MISSION.ONE", slice: "SLICE.ONE" },
{ level: "slice", code: "EIO", mission: "release", slice: "SLICE.ONE" },
])("reports unavailable when existing $level SPEC lookup fails with $code", async row => {
const file = row.level === "mission"
? path.join(source.missions, "release", "SPEC.md")
: path.join(source.missions, "release", "slices", "build", "SPEC.md");
expect(fs.statSync(file).isFile()).toBe(true);
const read = fs.readFileSync.bind(fs);
let faults = 0;
vi.spyOn(fs, "readFileSync").mockImplementation(((...args: unknown[]) => {
if (String(args[0]) === file) {
faults++;
throw Object.assign(new Error("fixture SPEC read failed"), { code: row.code });
}
return (read as (...input: unknown[]) => unknown)(...args);
}) as typeof fs.readFileSync);
await create(source.app, ["--mission", row.mission, "--slice", row.slice, "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", [`mission:${row.mission}`, `slice:${row.slice}`, "project:alpha"]);
expect(faults).toBeGreaterThan(0);
expect(scopeLines()).toMatch(/unavailable|could not (?:run|check)|unable to (?:run|check)/i);
expect(scopeLines()).not.toMatch(/resolves to 0|checked.*unresolved/i);
});
it("keeps absent unrelated metadata distinct from an unreadable existing SPEC", async () => {
fs.unlinkSync(path.join(source.missions, "duplicate-a", "SPEC.md"));
await create(source.app, ["--mission", "MISSION.ONE", "--slice", "SLICE.ONE", "--tags", "project:alpha"], true);
committed(source, "qitem-create-contract", ["mission:MISSION.ONE", "slice:SLICE.ONE", "project:alpha"]);
expect(scopeLines()).toBe("");
});
it("keeps the unavailable notice for ordinary long scope tags without changing the committed result", async () => {
fs.writeFileSync(path.join(source.root, "workspace.yaml"), "projects: invalid\n");
const tags = ["project:alpha", ...longScopeTags()];
await create(source.app, ["--tags", tags.join(",")], true);
committed(source, "qitem-create-contract", tags);
expect(JSON.stringify(tags).length).toBeGreaterThan(1024);
const advisories = createResponses[0]?.data.advisories as Array<{ code?: unknown; message?: unknown }> | undefined;
expect(advisories).toEqual([expect.objectContaining({ code: "scope_check_unavailable", message: expect.any(String) })]);
const message = advisories?.[0]?.message as string;
for (const tag of tags.slice(1)) expect(message).toContain(tag);
expect(scopeLines()).toMatch(/unavailable|could not (?:run|check)|unable to (?:run|check)/i);
expect(scopeLines()).not.toMatch(/resolves to 0|checked.*unresolved/i);
});
it.each(["valid", "missing", "long tags"])("remote create is SKIPPED even when sender scope is %s", async senderState => {
const receiver = await fixture("receiver");
fs.rmSync(receiver.missions, { recursive: true });
fs.mkdirSync(receiver.missions);
if (senderState === "missing") {
fs.rmSync(source.missions, { recursive: true });
fs.mkdirSync(source.missions);
spec(path.join(receiver.missions, "release"), "MISSION.ONE");
spec(path.join(receiver.missions, "release", "slices", "build"), "SLICE.ONE");
}
const senderReads: string[] = [];
for (const method of ["readdirSync", "readFileSync", "statSync", "existsSync"] as const) {
const original = fs[method].bind(fs) as (...args: unknown[]) => unknown;
vi.spyOn(fs, method).mockImplementation(((...args: unknown[]) => {
if (String(args[0]) === path.join(source.root, "workspace.yaml")
|| String(args[0]) === source.missions || String(args[0]).startsWith(source.missions + path.sep)) {
senderReads.push(method + ":" + String(args[0]));
}
return original(...args);
}) as never);
}
let forwarded = 0;
const app = new Hono();
app.use("*", async (c, next) => {
const set = c.set.bind(c) as (key: string, value: unknown) => void;
for (const key of ["eventBus", "queueRepo", "inboxHandler", "outboxHandler", "rigRepo"] as const) set(key, source.daemon.deps[key]);
set("settingsStore", new SettingsStore(path.join(source.root, "settings.json")));
set("hostRegistryLoader", () => ({ ok: true, registry: { hosts: [
{ id: "remote-fixture", transport: "http", url: "http://remote-fixture.invalid:39001" },
] } }));
set("remoteFetchImpl", async (_url: unknown, init: RequestInit) => {
forwarded++;
const body = JSON.parse(String(init.body));
const previous = getSelfHostId();
setSelfHostId(receiver.hostId);
try {
return await receiver.app.request("/api/queue/create", { ...init,
headers: { "Content-Type": "application/json", "X-OpenRig-Session": body.sourceSession } });
} finally { setSelfHostId(previous); }
});
await next();
});
app.route("/api/queue", queueRoutes());
setSelfHostId(source.hostId);
const extraTags = senderState === "long tags" ? longScopeTags() : [];
await create(app, ["--host", "remote-fixture", "--mission", "release", "--slice", "SLICE.ONE", "--tags", ["project:alpha", ...extraTags].join(",")], true);
expect(forwarded).toBe(1);
expect(source.daemon.deps.queueRepo.getById("qitem-create-contract")).toBeNull();
const stored = receiver.daemon.deps.queueRepo.getById("qitem-create-contract");
expect(stored).toMatchObject({ destinationSession: "worker@scope", body: "scope contract body", state: "pending" });
expect(stored?.sourceSession).toBe(`writer@scope@${source.hostId}`);
expect(stored?.tags).toEqual(expect.arrayContaining(["mission:release", "slice:SLICE.ONE", "project:alpha"]));
expect(process.exitCode ?? 0).toBe(0);
expect(stdout).toHaveLength(1);
const output = JSON.parse(stdout[0]!) as Record<string, unknown>;
const { advisories: _advisories, ...primary } = output;
expect(primary).toEqual(JSON.parse(JSON.stringify(stored)));
expect(output).toEqual(createResponses[0]?.data);
expect(senderReads).toEqual([]);
if (senderState === "long tags") {
expect(stored?.tags).toEqual(["mission:release", "slice:SLICE.ONE", "project:alpha", ...extraTags, "cross-host", `from-host:${os.hostname()}`]);
expect(JSON.stringify(extraTags).length).toBeGreaterThan(1024);
}
const advisories = createResponses[0]?.data.advisories as Array<{ code?: unknown; message?: unknown }> | undefined;
expect(advisories).toEqual([expect.objectContaining({ code: "scope_check_skipped", message: expect.any(String) })]);
const message = advisories?.[0]?.message as string;
expect(message).toContain("mission:release");
expect(message).toContain("slice:SLICE.ONE");
if (senderState === "long tags") for (const tag of extraTags) expect(message).toContain(tag);
expect(scopeLines()).toMatch(/\bskipped\b/i);
expect(scopeLines()).not.toMatch(/unresolved|ambiguous|unavailable/i);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { QueueDeps } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
// `rig queue erase`: the file it reads, the request it sends, and what it refuses before sending anything.
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
function makeDeps(response: { status: number; data: unknown } = { status: 200, data: { ok: true } }) {
const posts: Array<{ path: string; body: unknown }> = [];
const deps = {
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: () => ({
post: vi.fn(async (urlPath: string, body: unknown) => {
posts.push({ path: urlPath, body });
return response;
}),
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
} as QueueDeps;
return { deps, posts };
}
describe("rig queue erase", () => {
let dir: string;
let errors: string[];
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("OPENRIG_SESSION_NAME", "ops-admin@n-ops");
dir = fs.mkdtempSync(path.join(os.tmpdir(), "queue-erase-"));
errors = [];
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
process.exitCode = undefined;
});
const run = (deps: QueueDeps, ...args: string[]) => createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "erase", ...args]);
it("reads a members file (one per line, comments skipped) and sends a dry run unless --apply", async () => {
const file = path.join(dir, "members.txt");
fs.writeFileSync(file, "# account n-acct\na-one@n-acct\n\n b-two@n-acct \n");
const { deps, posts } = makeDeps();
await run(deps, "--members", file);
await run(deps, "--members", file, "--apply");
expect(posts).toEqual([
{ path: "/api/queue/erase", body: { apply: false, members: ["a-one@n-acct", "b-two@n-acct"] } },
{ path: "/api/queue/erase", body: { apply: true, members: ["a-one@n-acct", "b-two@n-acct"] } },
]);
});
it("reads ids as a JSON array and passes the action", async () => {
const file = path.join(dir, "ids.json");
fs.writeFileSync(file, JSON.stringify(["qitem-1", "qitem-2"]));
const { deps, posts } = makeDeps();
await run(deps, "--ids", file, "--action", "scrub", "--apply");
expect(posts).toEqual([{ path: "/api/queue/erase", body: { apply: true, ids: ["qitem-1", "qitem-2"], action: "scrub" } }]);
});
it("passes the account rig with the members, and a ledger of report entries as given", async () => {
const members = path.join(dir, "members.json");
fs.writeFileSync(members, JSON.stringify(["a-one@n-acct"]));
const ledger = path.join(dir, "ledger.json");
const entries = [{ qitemId: "qitem-1", action: "scrub", state: "done" }, "qitem-2"];
fs.writeFileSync(ledger, JSON.stringify(entries));
const { deps, posts } = makeDeps();
await run(deps, "--members", members, "--rig", "rig-acct-id", "--apply");
await run(deps, "--ids", ledger, "--apply");
expect(posts).toEqual([
{ path: "/api/queue/erase", body: { apply: true, members: ["a-one@n-acct"], rig: "rig-acct-id" } },
{ path: "/api/queue/erase", body: { apply: true, ids: entries } },
]);
});
it("reads an erasure ledger's JSON lines as given: entries that keep their action and batch, and commit lines", async () => {
const ledger = path.join(dir, "ledger.jsonl");
fs.writeFileSync(ledger, '{"qitemId":"qitem-1","action":"scrub","reason":"retention-content","erasedAt":"t","batch":"b1"}\n{"batch":"b1","committed":true,"at":"t"}\n# a comment\n{"qitemId":"qitem-2","action":"delete"}\n');
const { deps, posts } = makeDeps();
await run(deps, "--ids", ledger, "--apply");
expect(posts).toEqual([{ path: "/api/queue/erase", body: { apply: true, ids: [
{ qitemId: "qitem-1", action: "scrub", reason: "retention-content", erasedAt: "t", batch: "b1" }, { batch: "b1", committed: true, at: "t" },
{ qitemId: "qitem-2", action: "delete" },
] } }]);
fs.writeFileSync(ledger, '{"qitemId": broken\n');
process.exitCode = undefined;
await run(deps, "--ids", ledger, "--apply");
expect(process.exitCode).toBe(1);
expect(posts).toHaveLength(1);
});
it("sends nothing when its file can't be read, or when there's no one to record as erasing", async () => {
const { deps, posts } = makeDeps();
await run(deps, "--members", path.join(dir, "missing.txt"));
expect(process.exitCode).toBe(1);
vi.stubEnv("OPENRIG_SESSION_NAME", "");
process.exitCode = undefined;
await run(deps, "--routine");
expect(process.exitCode).toBe(1);
expect(posts).toEqual([]);
expect(errors.join("\n")).toMatch(/Nothing was erased/);
await run(deps, "--routine", "--actor", "erase-runner@n-ops");
expect(posts).toEqual([{ path: "/api/queue/erase", body: { apply: false, routine: true, actorSession: "erase-runner@n-ops" } }]);
});
it("a daemon refusal exits nonzero", async () => {
const { deps } = makeDeps({ status: 400, data: { error: "erase_scope_required", message: "Name one scope" } });
await run(deps);
expect(process.exitCode).toBe(1);
});
});
@@ -0,0 +1,215 @@
// #2866 — `rig queue update --source <name>@external`: a routine running outside any managed seat can append a
// NOTE as itself, recorded as an external actor (claimed:v1), never as a seat. A REAL `queueCommand` invocation
// talks over LOOPBACK HTTP to the real queueRoutes + QueueRepository on a fully migrated test DB, so the recorded
// transition (actor + provenance) is read back from the store, not from a stubbed response.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import type Database from "better-sqlite3";
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 { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { queueRoutes } from "../../daemon/src/routes/queue.js";
import { DaemonClient } from "../src/client.js";
import { queueCommand } from "../src/commands/queue.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
let db: Database.Database;
let repo: QueueRepository;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let server: any;
let port = 0;
const seenHeaders: Array<string | undefined> = [];
const SELF_HOST_ID = "this-host";
const REMOTE_URL = "http://127.0.0.1:9";
const remoteCalls: string[] = [];
// Same hermetic isolation as queue-update-metadata-reject-cli.test.ts: ambient OPENRIG_URL / RIGGED_URL /
// OPENRIG_PORT win over the injected state file, so they are removed for the CLI-invocation window only.
const AMBIENT_KEYS = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"] as const;
const savedEnv: Record<string, string | undefined> = {};
function neutralizeAmbient(): void { for (const k of AMBIENT_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; } }
function restoreAmbient(): void { for (const k of AMBIENT_KEYS) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } }
beforeAll(async () => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
const app = new Hono();
app.use("*", async (c, next) => {
seenHeaders.push(c.req.header("x-openrig-session"));
c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next();
});
// The daemon's own host id, as /healthz reports it: a `--host` naming it resolves to this daemon (queueHostTarget).
app.get("/healthz", (c) => c.json({ ok: true, selfHostId: SELF_HOST_ID }));
app.route("/api/queue", queueRoutes());
await new Promise<void>((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); });
});
afterAll(async () => {
restoreAmbient();
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
db?.close();
});
function loopbackUrl(): string { return `http://127.0.0.1:${port}`; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function deps(): any {
return {
// One registered http host besides this daemon, so `--host other-host` resolves to a truly remote target.
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [{ id: "other-host", transport: "http", url: REMOTE_URL }] } }),
lifecycleDeps: { spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true, readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null), writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true },
clientFactory: (url: string) => {
// The remote host's client is built but must never be called: every method records the call and fails.
if (url === REMOTE_URL) {
return new Proxy({}, { get: (_t, prop) => async () => { remoteCalls.push(String(prop)); throw new Error(`remote ${String(prop)} called`); } });
}
if (url !== loopbackUrl()) throw new Error(`test-isolation guard: clientFactory url '${url}' !== loopback '${loopbackUrl()}'`);
return new DaemonClient(url);
},
};
}
async function runCli(args: string[]): Promise<{ out: string; exit: number | undefined }> {
const out: string[] = [];
const ol = console.log, oe = console.error, ow = process.stderr.write;
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
console.error = (...a: unknown[]) => { out.push(a.join(" ")); };
process.stderr.write = ((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; }) as typeof process.stderr.write;
process.exitCode = undefined;
neutralizeAmbient();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { await queueCommand(deps() as any).parseAsync(["node", "rig", ...args]); }
finally { restoreAmbient(); console.log = ol; console.error = oe; process.stderr.write = ow; }
const exit = process.exitCode; process.exitCode = 0;
return { out: out.join("\n"), exit };
}
type TransitionRow = { actor_session: string; state: string; transition_note: string | null; identity_provenance: string | null };
const transitions = (id: string) => db.prepare(
"SELECT actor_session, state, transition_note, identity_provenance FROM queue_transitions WHERE qitem_id = ? ORDER BY rowid",
).all(id) as TransitionRow[];
describe("#2866 rig queue update --source <name>@external (note only, external actor)", () => {
let id: string;
beforeEach(async () => {
// Outside any managed seat: neither seat env is set, so the CLI stamps no X-OpenRig-Session header.
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
seenHeaders.length = 0;
remoteCalls.length = 0;
id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "work" })).qitemId;
});
afterEach(() => { vi.unstubAllEnvs(); });
it("records the note with the external routine as its actor (claimed:v1), never a seat, and leaves the state alone", async () => {
const before = repo.getByIdOrThrow(id);
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", "nudged the owner", "--json"]);
expect(exit, out).toBeFalsy();
const last = transitions(id).at(-1)!;
expect(last).toMatchObject({ actor_session: "routine-nightly@external", transition_note: "nudged the owner", identity_provenance: "claimed:v1" });
expect(last.state).toBe(before.state);
expect(repo.getByIdOrThrow(id).state).toBe(before.state);
// No seat identity was forged on the wire: the request carried no X-OpenRig-Session header.
expect(seenHeaders.length).toBeGreaterThan(0);
expect(seenHeaders.every((h) => h === undefined)).toBe(true);
});
it("without a seat and without --source, says notes need a seat and names --source; nothing is sent", async () => {
const beforeTxns = transitions(id).length;
const { out, exit } = await runCli(["update", id, "--note", "nudged the owner"]);
expect(exit).toBe(1);
expect(out.startsWith("notes need a seat; an external routine can pass --source <name>@external")).toBe(true);
expect(seenHeaders).toEqual([]);
expect(transitions(id).length).toBe(beforeTxns);
});
it("without a seat, a state change still needs one: refused before any request, and the error names --source for notes", async () => {
const before = repo.getByIdOrThrow(id);
const { out, exit } = await runCli(["update", id, "--state", "done", "--closure-reason", "no-follow-on"]);
expect(exit).toBe(1);
expect(out).toContain("OPENRIG_SESSION_NAME is not set, so there's no seat to act as");
expect(out).toContain("notes need a seat; an external routine can pass --source <name>@external");
// D#52: a person approving their own ask (a host pairing) is pointed at the TUI or Steering.
expect(out).toContain("A person answering an ask addressed to them (a host pairing approval, say) does it in the TUI (rig tui, then attention) or in Steering");
expect(seenHeaders).toEqual([]);
expect(repo.getByIdOrThrow(id)).toEqual(before);
});
it("a blank seat env is no seat: the --source note is recorded as the external routine, not dropped", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", " ");
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", "blank env", "--json"]);
expect(exit, out).toBeFalsy();
expect(out).not.toContain("is ignored");
expect(transitions(id).at(-1)).toMatchObject({ actor_session: "routine-nightly@external", identity_provenance: "claimed:v1" });
});
it.each(["local", SELF_HOST_ID])("accepts --host %s, which resolves to this daemon: the note is recorded as the external routine", async (host) => {
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", `via --host ${host}`, "--host", host, "--json"]);
expect(exit, out).toBeFalsy();
expect(transitions(id).at(-1)).toMatchObject({ actor_session: "routine-nightly@external", transition_note: `via --host ${host}`, identity_provenance: "claimed:v1" });
});
it("refuses --source with a --host that resolves to another host (it would lose the routine's origin); nothing is written", async () => {
const beforeTxns = transitions(id).length;
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", "x", "--host", "other-host"]);
expect(exit).toBe(1);
expect(out).toContain("--source with --host other-host is not supported");
expect(out).toContain("Nothing was sent");
// Only the self-id probe reached this daemon; nothing was posted here or to the other host.
expect(remoteCalls).toEqual([]);
expect(transitions(id).length).toBe(beforeTxns);
});
it.each(["dev-x@rig", "routine-nightly", "routine@externalx", "human@kernel"])("refuses --source %s (not <name>@external); nothing is sent", async (source) => {
const beforeTxns = transitions(id).length;
const { out, exit } = await runCli(["update", id, "--source", source, "--note", "nudged the owner"]);
expect(exit).toBe(1);
expect(out).toContain("--source must be <name>@external");
expect(seenHeaders).toEqual([]);
expect(transitions(id).length).toBe(beforeTxns);
});
// Every flag of the update verb that writes more than a note, one case each (UPDATE_STATE_WRITE_FLAGS in queue.ts).
it.each([
["--state", ["--state", "in-progress"]],
["--reopen", ["--reopen"]],
["--closure-reason", ["--closure-reason", "no-follow-on"]],
["--closure-target", ["--closure-target", "next@rig"]],
["--closure-required-at", ["--closure-required-at", "2030-01-01T00:00:00Z"]],
["--resolution", ["--state", "done", "--closure-reason", "no-follow-on", "--resolution", "finished"]],
["--blocked-on", ["--state", "blocked", "--blocked-on", "external:x"]],
["--wake-watchdog", ["--wake-watchdog", "job-1"]],
["--wake-after", ["--wake-after", "15m"]],
["--summary", ["--summary", "new subject"]],
["--evidence-ref", ["--evidence-ref", "/proof.md"]],
] as const)("refuses --source with %s: it records a note only; nothing is sent", async (_label, extra) => {
const before = repo.getByIdOrThrow(id);
const beforeTxns = transitions(id).length;
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", "x", ...extra]);
expect(exit).toBe(1);
expect(out).toContain("--source records a note only");
expect(seenHeaders).toEqual([]);
expect(transitions(id).length).toBe(beforeTxns);
expect(repo.getByIdOrThrow(id)).toEqual(before);
});
it("refuses --source without --note; nothing is sent", async () => {
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external"]);
expect(exit).toBe(1);
expect(out).toContain("--source records a note only");
expect(seenHeaders).toEqual([]);
});
it("inside a managed seat the seat stays the actor (transport:v1); --source does not override it", async () => {
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-x@rig");
const { out, exit } = await runCli(["update", id, "--source", "routine-nightly@external", "--note", "seat note", "--json"]);
expect(exit, out).toBeFalsy();
expect(transitions(id).at(-1)).toMatchObject({ actor_session: "dev-x@rig", transition_note: "seat note", identity_provenance: "transport:v1" });
expect(out).toContain("warning: --source routine-nightly@external is ignored: this runs in the seat dev-x@rig");
});
});
+23
View File
@@ -1367,6 +1367,29 @@ describe("--help is present on every command (HG-12)", () => {
});
});
describe("slice and mission verb help names the parent --workspace option", () => {
const helpOf = (cmd: Command): string => {
let out = "";
cmd.configureOutput({ writeOut: (text) => { out += text; } });
cmd.outputHelp();
return out;
};
it("every slice and mission verb, and each tier, shows the rig scope --workspace route", () => {
const cmd = scopeCommand();
for (const tierName of ["slice", "mission"] as const) {
const tier = cmd.commands.find((c) => c.name() === tierName)!;
expect(helpOf(tier)).toContain(`rig scope --workspace <project root> ${tierName} <verb> ...`);
for (const verb of tier.commands) {
const help = helpOf(verb);
expect(help, `${tierName} ${verb.name()}`).toContain(`rig scope --workspace <project root> ${tierName} ${verb.name()} ...`);
expect(help, `${tierName} ${verb.name()}`).toContain("$(rig config get workspace.projects_root)/<project>");
expect(help, `${tierName} ${verb.name()}`).not.toContain("~/.openrig");
}
}
});
});
describe("rig scope audit ordinal naming", () => {
let root: string;
let mission: string;
+30
View File
@@ -512,6 +512,36 @@ describe("findSlice + resolution variants", () => {
it("3-part error when slice not found (HG-10)", () => {
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
});
it("O2: an absolute slice in another workspace's missions tree names the --workspace that selects it", () => {
const project = path.join(root, "projects", "alpha");
const slice = path.join(project, "missions", "m1", "slices", "01-x");
writeFile(path.join(project, "missions", "m1", "SPEC.md"), "---\nid: ALPHA.99.0.1\n---\nbody");
writeFile(path.join(slice, "SPEC.md"), "---\nid: ALPHA.99.0.1.1\n---\nbody");
let error: unknown;
try { findSlice(missionsRoot, slice); } catch (err) { error = err; }
expect(error).toBeInstanceOf(ScopeCliError);
expect((error as ScopeCliError).fact).toBe(`Slice path "${slice}" resolved to ${slice} but no parent mission was found.`);
expect((error as ScopeCliError).action).toBe(
`The slice is in another workspace's missions tree (${path.join(project, "missions")}), not under ${missionsRoot}. `
+ `Select that tree with the parent --workspace option, for example: rig scope --workspace '${project}' slice <verb> '${slice}'`,
);
// The named option works: that workspace resolves the slice.
expect(findSlice(resolveMissionsRoot({ override: project, strictOverride: true }), slice).id).toBe("ALPHA.99.0.1.1");
});
it("O2: a slice outside any missions tree keeps the old advice and adds the --workspace route", () => {
const scratch = path.join(root, "07-target");
fs.mkdirSync(scratch);
expect(() => findSlice(missionsRoot, scratch)).toThrow(
"Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/. For a slice in another workspace, such as a catalog project, "
+ "select its tree with the parent option: rig scope --workspace <project root> slice <verb> <slice-path>",
);
});
it("O2: a mission not found names the --workspace route", () => {
expect(() => findMission(missionsRoot, "absent")).toThrow("rig scope --workspace <project root> mission <verb> <mission>");
});
});
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
+138
View File
@@ -0,0 +1,138 @@
import { expect, it, vi } from "vitest";
import { attentionLines, checkKernelAttention, checkSeatAttention } from "../src/seat-attention.js";
type Reply = { status: number; data: unknown } | Error;
function client(routes: Record<string, Reply>) {
const get = vi.fn(async (url: string) => {
const reply = routes[url];
if (reply instanceof Error) throw reply;
return reply ?? { status: 404, data: { error: "not found" } };
});
return { get, client: { get } as unknown as Parameters<typeof checkSeatAttention>[0] };
}
const LOGIN = "Startup requires attention: Login expired · Please run /login";
const nodes = [
// A live question the daemon serves (needs-input), with its own reason.
{ canonicalSessionName: "question@r", needsAttention: true, arbitratedNeedsInput: true, startupStatus: "ready", latestError: null,
activityState: { activity: "needs_input", display: "needs input", needsInput: { count: 1, reason: "selection_prompt" }, decidedBy: "pane" } },
// Broad attention that isn't a question: a ready seat started without required guidance.
{ canonicalSessionName: "guidance@r", needsAttention: true, arbitratedNeedsInput: false, startupStatus: "ready", latestError: null,
startedWithoutRequired: ["/seat/AGENTS.md"] },
// A failed start: a readiness timeout, as a dialog the startup check doesn't recognise leaves it.
{ canonicalSessionName: "failed@r", needsAttention: true, startupStatus: "failed",
latestError: "Readiness timeout after 30s — harness did not become interactive: unknown" },
// An expired sign-in at startup.
{ canonicalSessionName: "login@r", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN },
{ canonicalSessionName: "fine@r", needsAttention: false, startupStatus: "ready", latestError: null },
// An older daemon that serves no attention evidence.
{ canonicalSessionName: "old@r", startupStatus: "ready", latestError: null },
];
it("honours the daemon's needs-input precedence: a served 'no' beats a stale hook; the hook counts only without an answer", async () => {
const precedence = [
// Broad attention (missing guidance), the arbitrated answer is no question, and a stale needs_input hook remains.
{ canonicalSessionName: "stale@r", needsAttention: true, arbitratedNeedsInput: false, startupStatus: "ready", latestError: null,
startedWithoutRequired: ["/seat/AGENTS.md"], agentActivity: { state: "needs_input", reason: "permission_prompt" } },
// The same stale hook as the only reason: its reason isn't revived either.
{ canonicalSessionName: "staleonly@r", needsAttention: true, arbitratedNeedsInput: false, startupStatus: "ready", latestError: null,
agentActivity: { state: "needs_input", reason: "permission_prompt" } },
// No arbitrated answer: the hook decides, with its reason.
{ canonicalSessionName: "hook@r", needsAttention: true, arbitratedNeedsInput: null, startupStatus: "ready", latestError: null,
agentActivity: { state: "needs_input", reason: "permission_prompt" } },
];
const { client: c } = client({ "/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "r" }] }, "/api/rigs/r1/nodes": { status: 200, data: [...nodes, ...precedence] } });
const check = await checkSeatAttention(c, ["stale@r", "staleonly@r", "hook@r", "question@r"]);
expect(check.attention).toEqual([
{ seat: "question@r", reason: "selection_prompt", waitingOnPerson: true },
{ seat: "stale@r", reason: "missing required AGENTS.md", waitingOnPerson: false },
{ seat: "staleonly@r", reason: "reason unavailable", waitingOnPerson: false },
{ seat: "hook@r", reason: "permission_prompt", waitingOnPerson: true },
]);
});
it("labels only a served live question as waiting on a person; the broad flag reads 'needs attention' with its reason", async () => {
const { client: c } = client({ "/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "r" }] }, "/api/rigs/r1/nodes": { status: 200, data: nodes } });
const check = await checkSeatAttention(c, ["question@r", "guidance@r", "failed@r", "login@r", "fine@r", "old@r"]);
expect(check.attention).toEqual([
{ seat: "question@r", reason: "selection_prompt", waitingOnPerson: true },
{ seat: "guidance@r", reason: "missing required AGENTS.md", waitingOnPerson: false },
{ seat: "failed@r", reason: "Readiness timeout after 30s — harness did not become interactive: unknown", waitingOnPerson: false },
{ seat: "login@r", reason: LOGIN, waitingOnPerson: false },
]);
expect(check.unknown).toEqual([{ seat: "old@r", why: "the daemon served no attention evidence for it" }]);
expect(attentionLines(check, "tile")).toEqual([
"question@r is waiting on a person: selection_prompt. Answer it in its tile, or read it with: rig capture question@r",
"guidance@r needs attention: missing required AGENTS.md. Look at it in its tile with: rig capture guidance@r",
"failed@r needs attention: Readiness timeout after 30s — harness did not become interactive: unknown. Look at it in its tile with: rig capture failed@r",
`login@r needs attention: ${LOGIN}. Look at it in its tile with: rig capture login@r`,
"Couldn't check whether old@r needs attention: the daemon served no attention evidence for it.",
]);
});
it("an unreadable rig list leaves every seat unknown, not unflagged", async () => {
const { client: c } = client({ "/api/ps": { status: 503, data: { error: "busy" } } });
const check = await checkSeatAttention(c, ["a@r", "b@r"]);
expect(check).toEqual({ attention: [], unknown: [
{ seat: "a@r", why: "the rig list couldn't be read (HTTP 503: busy)" },
{ seat: "b@r", why: "the rig list couldn't be read (HTTP 503: busy)" }] });
expect(attentionLines(check, "pane")).toEqual(["Couldn't check whether a@r, b@r need attention: the rig list couldn't be read (HTTP 503: busy)."]);
});
it("one rig's failed read keeps the other rig's answers and leaves only its own seats unknown", async () => {
const { client: c } = client({
"/api/ps": { status: 200, data: [{ rigId: "a1", rigName: "a" }, { rigId: "b1", rigName: "b" }] },
"/api/rigs/a1/nodes": { status: 200, data: [{ canonicalSessionName: "x@a", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN }] },
"/api/rigs/b1/nodes": new Error("socket hang up"),
});
const check = await checkSeatAttention(c, ["x@a", "y@b"]);
expect(check.attention).toEqual([{ seat: "x@a", reason: LOGIN, waitingOnPerson: false }]);
expect(check.unknown).toEqual([{ seat: "y@b", why: "b's seats couldn't be read (socket hang up)" }]);
});
it("a seat that isn't in this daemon's inventory is unknown, never 'fine'", async () => {
const { client: c } = client({ "/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "r" }] }, "/api/rigs/r1/nodes": { status: 200, data: [] } });
const check = await checkSeatAttention(c, ["gone@r", "x@elsewhere"]);
expect(check.unknown).toEqual([
{ seat: "gone@r", why: "not in r's seats on this daemon" },
{ seat: "x@elsewhere", why: "no rig named elsewhere on this daemon" }]);
});
it("an unreadable kernel status is reported, not read as no held seat", async () => {
const { client: c } = client({ "/api/kernel/status": { status: 503, data: { error: "kernel_boot_tracker_unavailable" } } });
expect(await checkKernelAttention(c)).toEqual({ attention: [],
unknown: [{ seat: "kernel", why: "the kernel status couldn't be read (HTTP 503: kernel_boot_tracker_unavailable)" }] });
});
it("keeps a long unknown list to one bounded line per reason", () => {
const unknown = ["a", "b", "c", "d", "e", "f", "g"].map(name => ({ seat: `${name}@r`, why: "down" }));
expect(attentionLines({ attention: [], unknown }, "pane"))
.toEqual(["Couldn't check whether a@r, b@r, c@r, d@r, e@r and 2 more need attention: down."]);
});
it("a seat waiting on a person names its question, not an older recorded error; the needs-attention order is unchanged", async () => {
const OLD = "Readiness timeout after 30s — harness did not become interactive: unknown";
const seats = [
// A live question, served by the arbitrated answer, on a seat that also carries an older startup error.
{ canonicalSessionName: "asked@r", needsAttention: true, arbitratedNeedsInput: true, startupStatus: "failed", latestError: OLD,
activityState: { activity: "needs_input", display: "needs input", needsInput: { count: 1, reason: "permission_prompt" }, decidedBy: "hook" } },
// A live question from the hook alone (no arbitrated answer), with the same older error.
{ canonicalSessionName: "hooked@r", needsAttention: true, arbitratedNeedsInput: null, startupStatus: "failed", latestError: OLD,
agentActivity: { state: "needs_input", reason: "elicitation_dialog" } },
// A live question whose reason isn't served: never the older error.
{ canonicalSessionName: "unsaid@r", needsAttention: true, arbitratedNeedsInput: true, startupStatus: "failed", latestError: OLD,
activityState: { activity: "needs_input", display: "needs input", needsInput: { count: 1, reason: null }, decidedBy: "hook" } },
// No question: the recorded error still leads.
{ canonicalSessionName: "broad@r", needsAttention: true, arbitratedNeedsInput: false, startupStatus: "failed", latestError: OLD,
agentActivity: { state: "needs_input", reason: "permission_prompt" } },
];
const { client: c } = client({ "/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "r" }] }, "/api/rigs/r1/nodes": { status: 200, data: seats } });
const check = await checkSeatAttention(c, ["asked@r", "hooked@r", "unsaid@r", "broad@r"]);
expect(check.attention).toEqual([
{ seat: "asked@r", reason: "permission_prompt", waitingOnPerson: true },
{ seat: "hooked@r", reason: "elicitation_dialog", waitingOnPerson: true },
{ seat: "unsaid@r", reason: "reason unavailable", waitingOnPerson: true },
{ seat: "broad@r", reason: OLD, waitingOnPerson: false },
]);
expect(attentionLines(check, "tile")[0]).toBe("asked@r is waiting on a person: permission_prompt. Answer it in its tile, or read it with: rig capture asked@r");
});
@@ -67,6 +67,13 @@ const plan = {
describe("rig seat set-runtime", () => {
afterEach(() => vi.restoreAllMocks());
it("passes a Pi target and its selected account through the existing dry-run workflow", async () => {
const { calls, exitCode } = await run({ status: 200, data: { ...plan, to: "pi", accountHome: "/accounts/pi" } },
["dev.impl@my-rig", "--runtime", "pi", "--account-home", "/accounts/pi", "--reason", "Change runtime", "--dry-run"]);
expect(calls[0]!.body).toMatchObject({ runtime: "pi", accountHome: "/accounts/pi", reason: "Change runtime", dryRun: true });
expect(exitCode).toBeFalsy();
});
it("an older daemon cannot silently ignore the chosen home and execute a switch", async () => {
const { calls, exitCode } = await run({ status: 200, data: plan }, ["dev.impl@my-rig", "--runtime", "claude-code",
"--account-home", "/accounts/claude-api", "--reason", "Plan limit", "--json"]);
+18
View File
@@ -788,6 +788,24 @@ describe("rig seat status", () => {
else expect(logs.join("\n")).toContain(`Codex rebuild permissions: unverified. ${permissions.detail}`);
});
it.each([[false, "chosen-model"], [false, null], [true, "chosen-model"], [true, null]] as const)(
"reports rebuild model selection without a native comparison (json=%s, model=%s)", async (json, requested) => {
const model = { requested, predecessorCompared: false };
const response = { ...HANDOVER_RESULT, source: { mode: "rebuild", raw: "rebuild" },
sourceOutcome: { mode: "rebuild", primedArtifacts: [], gaps: [], model } };
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(makeDeps({ status: 200, data: response }, [])).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig",
"--source", "rebuild", "--reason", "context wall", ...(json ? ["--json"] : [])]);
});
expect(exitCode).toBeUndefined();
if (json) expect(JSON.parse(logs.join("\n")).sourceOutcome.model).toEqual(model);
else {
expect(logs.join("\n")).toContain("Rebuild starts a new conversation; durable artifacts carry its context, not a native resume.");
expect(logs.join("\n")).toContain(requested ? `Codex rebuild model: requested ${requested}` : "Codex rebuild model: runtime configuration/default");
expect(logs.join("\n")).toContain("The predecessor's in-pane model was not compared or carried.");
}
});
it("handover non-dry-run human output avoids continuity claims (discovered, no context delivery)", async () => {
const deps = makeDeps({ status: 200, data: HANDOVER_RESULT }, []);
+16
View File
@@ -144,6 +144,22 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain("Sent: 08-06 17:42Z · gen a1b2c3d4");
});
// ── #2895 (a): a sender that `rig send` can't reach gets no `rig send` reply line ──
// MIRROR of packages/daemon/test/pane-envelope.test.ts against wrapPaneEnvelope (byte-identity contract).
it("#2895 (a): senderReachable false replaces the rig send line with a plain note; absent or true keeps it", () => {
const meta = { stampISO: "2026-08-06T17:42:09Z" };
expect(wrapSendBody("planner@global", "b@r", "hi", { ...meta, senderReachable: false }))
.toBe("From: planner@global\nTo: b@r\nSent: 08-06 17:42Z\n---\nhi\n---\n↩ No reply by rig send: planner@global has no session.");
expect(wrapSendBody("a@r", "b@r", "hi", { ...meta, senderReachable: true })).toBe(wrapSendBody("a@r", "b@r", "hi", meta));
expect(wrapSendBody("a@r", "b@r", "hi", meta).split("\n").at(-1)).toBe('↩ Reply: rig send a@r "..."');
});
it("#2895 (a): senderReachable false leaves the external reply line alone", () => {
for (const sender of ["alex@external"]) {
expect(wrapSendBody(sender, "b@r", "hi", { senderReachable: false })).toBe(wrapSendBody(sender, "b@r", "hi"));
}
});
it("(g) byte-exact full envelope with gen (cross-package parity anchor)", () => {
const out = wrapSendBody("a@r", "b@r", "hi", { stampISO: "2026-08-06T17:42:09Z", genUuid: GEN });
expect(out).toBe('From: a@r\nTo: b@r\nSent: 08-06 17:42Z · gen a1b2c3d4\n---\nhi\n---\n↩ Reply: rig send a@r "..."');
+25
View File
@@ -402,6 +402,31 @@ describe("Send CLI", () => {
expect(logs.join("\n")).not.toContain("typed, never submitted");
});
it("fan-out text output keeps each recipient's own advisory, such as unreadable input", async () => {
const unreadable = "Input could not be read and was not cleared; this send may join or submit existing text.";
const client = {
post: async (path: string) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 2, sent: 2, failed: 0, results: [
{ sessionName: "worker@test", ok: true, existingInput: { classification: "unreadable", fullContentsVisible: false }, warning: unreadable },
{ sessionName: "sibling@test", ok: true, existingInput: { classification: "unreadable", fullContentsVisible: false }, warning: `${unreadable} No trailing Enter.` },
],
} };
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "worker@test,sibling@test", "1", "--dangerously-interact", "--reason", "fixture",
]);
});
const output = logs.join("\n");
expect(output).toContain(`worker@test: Advisory: ${unreadable}`);
expect(output).toContain(`sibling@test: Advisory: ${unreadable} No trailing Enter.`);
expect(output).toContain("worker@test: Existing input: unreadable");
expect(output).toContain("2/2 delivered");
});
it("reports a fan-out refusal as unconfirmed alongside a successful recipient", async () => {
let submits = 0;
const client = {
+2 -1
View File
@@ -1111,7 +1111,8 @@ describe("rig setup: the router-line question", () => {
expect(routerStep(result)).toMatchObject({ status: "skipped", message: expect.stringContaining("it isn't a yes"), fixHint: "rig setup --router-line yes, or rig setup --router-line no" });
expect(result.routerLine).toMatchObject({ answer: "unasked", files: expect.stringContaining("AGENTS.md"), limit: expect.stringContaining("doesn't remove a line already written") });
expect(result.routerLine.line).toContain("rig skill loadout --seat self");
expect(result.routerLine.ifNo).toContain("Nothing is written into these files");
expect(result.routerLine.ifNo).toContain("This line isn't written");
expect(result.routerLine.ifNo).toContain("names the `openrig-skills` router");
expect(set).not.toHaveBeenCalled();
expect(store.resolveWithSource("skills.router_line.enabled").source).toBe("default");
});
@@ -88,6 +88,37 @@ describe("rig skill loadout --seat", () => {
expect(logs.some((l) => l.startsWith("router") || l.startsWith("bootstrap") || l.startsWith("agent") || l.startsWith("prompt"))).toBe(false);
});
it("#2895 (b): a seat on a runtime without seat skills prints the daemon's note and exits 0, in text and in --json", async () => {
process.env["OPENRIG_SESSION_NAME"] = "dev-impl@team";
const note = "dev-impl@team is a pi-tui seat; seat skills are kept for Claude Code and Codex seats";
const unsupported = { seat: { sessionName: "dev-impl@team", runtime: "pi-tui" }, unsupported: true, note,
storage: null, skills: [], catalogDigest: null, occupant: null, delivery: null };
const { deps } = makeDeps({ status: 200, data: unsupported });
await skillCommand(deps).parseAsync(["node", "rig", "loadout", "--seat", "self"]);
expect(logs).toEqual([note]);
expect(errors).toEqual([]);
expect(process.exitCode).toBeUndefined();
logs.length = 0;
await skillCommand(deps).parseAsync(["node", "rig", "loadout", "--seat", "self", "--json"]);
expect(JSON.parse(logs.join("\n"))).toEqual(unsupported);
expect(process.exitCode).toBeUndefined();
});
it("#2895 (b): an unknown session and --apply on a runtime without seat skills still exit 1 with the daemon's error", async () => {
process.env["OPENRIG_SESSION_NAME"] = "nobody@nowhere";
const unknown = makeDeps({ status: 404, data: { error: "no OpenRig seat has the session nobody@nowhere" } });
await skillCommand(unknown.deps).parseAsync(["node", "rig", "loadout", "--seat", "self"]);
expect(errors).toEqual(["no OpenRig seat has the session nobody@nowhere"]);
expect(process.exitCode).toBe(1);
errors.length = 0; process.exitCode = undefined;
process.env["OPENRIG_SESSION_NAME"] = "dev-impl@team";
const refused = "dev-impl@team is a pi-tui seat; seat skills are kept for Claude Code and Codex seats";
const apply = makeDeps({ status: 200, data: {} }, { status: 404, data: { error: refused } });
await skillCommand(apply.deps).parseAsync(["node", "rig", "loadout", "--seat", "self", "--apply"]);
expect(errors).toEqual([refused]);
expect(process.exitCode).toBe(1);
});
it("says plainly that a shell without a seat identity is not an OpenRig seat, and shows no one else's skills", async () => {
delete process.env["OPENRIG_SESSION_NAME"];
const { deps, client } = makeDeps({ status: 200, data: inspection });
@@ -34,6 +34,21 @@ describe("rig slack post-open-asks", () => {
expect(logs.join("\n")).toMatch(/Run `rig slack post-open-asks` again later .* reuses each ask's decision rather than offering it again/);
});
it("prints an ask that may already be in Slack as uncertain, with the no-receipt line, never as a transport failure", async () => {
const { d, logs } = deps({ status: 200, data: { ok: true, asks: [
{ qitemId: "q-7", recipient: "alex@external", summary: "Approve?", outcome: "uncertain",
detail: "may already be in Slack (its reply couldn't be read); the gateway checks Slack for its marker before any resend when it next starts" },
{ qitemId: "q-8", recipient: "alex@external", summary: "Ship?", outcome: "uncertain",
detail: "may already be in Slack (its reply couldn't be read); no resend is pending" },
] } });
await run(d, ["post-open-asks"]);
expect(logs[0]).toBe("2 open ask(s): 2 uncertain");
expect(logs[1]).toBe(" uncertain: q-7 for alex@external: Approve? (may already be in Slack (its reply couldn't be read); the gateway checks Slack for its marker before any resend when it next starts)");
expect(logs[2]).toBe(" uncertain: q-8 for alex@external: Ship? (may already be in Slack (its reply couldn't be read); no resend is pending)");
expect(logs.join("\n")).toMatch(/Queued or uncertain asks have no Slack receipt yet/);
expect(logs.join("\n")).not.toMatch(/transport fail/i);
});
it("says the daemon's reason and exits 1 when Slack isn't posting, and when the daemon can't be reached", async () => {
for (const response of [
{ status: 409, data: { error: "slack_not_posting", message: "Slack isn't enabled on this machine, so no ask was shown. Run rig slack enable first, then this." } },
+48
View File
@@ -49,10 +49,17 @@ function createDaemonServer(
summaryData: unknown[],
cmuxStatus: unknown,
kernelStatus?: { code: number; body: unknown },
more: Record<string, unknown> = {},
) {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname in more) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(more[url.pathname]));
return;
}
if (url.pathname === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(summaryData));
@@ -252,6 +259,47 @@ describe("rig status", () => {
}
});
it("names a kernel seat that needs attention, and why, under a partial_ready kernel (D#52)", async () => {
const held = createDaemonServer([], { available: false }, {
code: 200,
body: { kernel_state: "partial_ready", agents: [
{ session_name: "advisor-lead@kernel", runtime: "claude-code", startup_status: "attention_required" },
{ session_name: "operator-agent@kernel", runtime: "claude-code", startup_status: "ready" },
] },
}, {
"/api/ps": [{ rigId: "k1", rigName: "kernel" }],
"/api/rigs/k1/nodes": [
{ canonicalSessionName: "advisor-lead@kernel", needsAttention: true, startupStatus: "attention_required",
latestError: "Startup requires attention: Login expired · Please run /login" },
{ canonicalSessionName: "operator-agent@kernel", needsAttention: false, startupStatus: "ready", latestError: null },
],
});
const heldPort = await held.listen();
try {
const deps = () => ({
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(runningState(heldPort)) : null)),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl: string) => new DaemonClient(baseUrl),
});
const human = new Command();
human.addCommand(statusCommand(deps()));
const output = (await captureLogs(() => human.parseAsync(["node", "rig", "status"]))).join("\n");
expect(output).toContain("Kernel: partial_ready");
expect(output).toContain(" advisor-lead@kernel needs attention: Startup requires attention: Login expired · Please run /login. Look at it with: rig capture advisor-lead@kernel");
expect(output).not.toContain("operator-agent@kernel needs attention");
const json = new Command();
json.addCommand(statusCommand(deps()));
const parsed = JSON.parse((await captureLogs(() => json.parseAsync(["node", "rig", "status", "--json"]))).join("\n"));
expect(parsed.kernel.attention).toEqual({ unknown: [], attention: [
{ seat: "advisor-lead@kernel", reason: "Startup requires attention: Login expired · Please run /login", waitingOnPerson: false }] });
} finally {
await held.close();
}
});
it("marks the workspace root as an override when OPENRIG_WORKSPACE_ROOT is set", async () => {
const prev = process.env.OPENRIG_WORKSPACE_ROOT;
process.env.OPENRIG_WORKSPACE_ROOT = process.cwd(); // a real existing dir
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { herdrAppClient, terminalCommand, type TerminalDeps } from "../src/commands/terminal.js";
import type { DaemonClient } from "../src/client.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
const inApp = { HC_TERMINAL_ID: "t4", HC_TERMINAL_SOCKET: "/run/core.sock", HERDR_APP_HOME: "/apps/h" };
const deps = (env: NodeJS.ProcessEnv, socket = true) => ({
env, socketExists: (p: string) => socket && p === "/apps/h/herdr-app.sock",
}) as Parameters<typeof herdrAppClient>[1];
afterEach(() => vi.restoreAllMocks());
describe("rig terminal open: when herdr-app is the target", () => {
it("--provider herdr-app resolves herdr-app's home (or explicit --app-home)", () => {
expect(herdrAppClient({ provider: "herdr-app" }, deps(inApp))).toEqual({ appHome: "/apps/h", ambient: false });
expect(herdrAppClient({ provider: "herdr-app", appHome: "/x" }, deps({}))).toEqual({ appHome: "/x", ambient: false });
});
it("with neither --provider nor --window: a herdr-app terminal whose window socket exists chooses herdr-app", () => {
expect(herdrAppClient({}, deps(inApp))).toEqual({ appHome: "/apps/h", ambient: true });
expect(herdrAppClient({}, deps({ HERDR_APP_HOME: "/apps/h" }))).toBeNull();
expect(herdrAppClient({}, deps(inApp, false))).toBeNull(); // markers alone invent no endpoint
});
it("--window and other named providers keep their meaning", () => {
expect(herdrAppClient({ window: true }, deps(inApp, true))).toBeNull();
expect(herdrAppClient({ provider: "herdr-app", window: true }, deps(inApp, true))).toBeNull();
expect(herdrAppClient({ provider: "herdr" }, deps(inApp))).toBeNull();
expect(herdrAppClient({ provider: "cmux" }, deps(inApp))).toBeNull();
const openCmd = terminalCommand().commands.find((c) => c.name() === "open");
const windowOpt = openCmd?.options.find((o) => o.long === "--window");
expect(windowOpt?.description).toContain("still asks for a desktop window");
const providerOpt = openCmd?.options.find((o) => o.long === "--provider");
expect(providerOpt?.description).not.toContain(")(");
expect(providerOpt?.description).not.toContain(") (");
});
it("probing an ambient herdr-app window: answering opens in herdr-app; unanswering or unconfirmed status falls back to desktop with an accurate note; explicit --provider herdr-app does not fall back", async () => {
const runOpen = async (args: string[], env: NodeJS.ProcessEnv, probeReply: { status: number; available?: boolean }) => {
const posts: Array<{ path: string; body: unknown }> = [];
const gets: string[] = [];
const logs: string[] = [];
const errs: string[] = [];
vi.spyOn(console, "log").mockImplementation((m) => { logs.push(String(m)); });
vi.spyOn(console, "error").mockImplementation((m) => { errs.push(String(m)); });
const client = {
baseUrl: "http://localhost:7433",
get: async (path: string) => {
gets.push(path);
if (path.startsWith("/api/terminal/status?provider=herdr-app")) {
return probeReply.available === undefined
? { status: probeReply.status, data: { error: "not_found" } }
: { status: probeReply.status, data: { providers: [{ name: "herdr-app", status: { available: probeReply.available, launch: { socketPath: "/apps/h/herdr-app.sock" } } }] } };
}
if (path === "/api/terminal/status" || path.startsWith("/api/terminal/status?provider=herdr")) {
return {
status: 200,
data: {
available: true,
launch: { binary: "/bin/herdr", socketPath: "/tmp/h.sock", session: "default" },
providers: [{ name: "herdr", liveness: { alive: true }, status: { available: true, launch: { binary: "/bin/herdr", socketPath: "/tmp/h.sock", session: "default" } } }],
},
};
}
if (path.startsWith("/api/terminal/preview")) {
return {
status: 200,
data: {
planId: "p1",
status: { launch: { socketPath: "/tmp/h.sock" } },
composed: {
id: "slice:s1",
pages: [[{ seat: "dev.build@demo", label: "dev.build", readOnly: false, paneCommand: "tmux attach -t =dev.build@demo" }]],
opened: [{ seat: "dev.build@demo", label: "dev.build", paneCommand: "tmux attach -t =dev.build@demo" }],
absent: [],
degraded: [],
},
},
};
}
return { status: 200, data: {} };
},
post: async (path: string, body: unknown) => {
posts.push({ path, body });
const b = body as { provider?: string };
if (path === "/api/terminal/follow") {
return { status: 200, data: { provider: "herdr", ok: true, workspaceId: "ws-1", opened: ["dev.build@demo"], absent: [], degraded: [], pages: 1 } };
}
if (b.provider === "herdr-app" && probeReply.available !== true) {
return { status: 200, data: { provider: "herdr-app", ok: false, code: "herdr_app_unavailable", error: "herdr-app is not answering", opened: [], absent: [], degraded: [], pages: 0 } };
}
return { status: 200, data: { provider: b.provider ?? "herdr", ok: true, opened: ["dev.build@demo"], absent: [], degraded: [], pages: 1 } };
},
} as unknown as DaemonClient;
const cmdDeps: TerminalDeps = {
env,
socketExists: (p: string) => p === "/apps/h/herdr-app.sock",
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"],
clientFactory: () => client,
windowDeps: {
env: { DISPLAY: ":0", TERM_PROGRAM: "herdr", HERDR_SOCKET_PATH: "/tmp/h.sock" },
platform: "linux",
exists: () => true,
exec: async (_file: string, execArgs: string[]) => {
if (execArgs[1] === "command -v herdr") return "/bin/herdr\n";
return JSON.stringify({
result: {
running: true,
socket: "/tmp/h.sock",
workspaces: [{ workspace_id: "ws-1", label: "s1", focused: true }],
tabs: [{ workspace_id: "ws-1", tab_id: "tb-1", label: "s1" }],
tab: { workspace_id: "ws-1", tab_id: "tb-1" },
},
});
},
launch: async () => {},
sleep: async () => {},
id: () => "id1",
herdrConfig: () => "/tmp/herdr.json",
},
};
await terminalCommand(cmdDeps).parseAsync(["node", "terminal", "open", ...args]);
return { posts, gets, logs, errs };
};
// 1. Ambient + available:true -> opens in herdr-app
const okAmbient = await runOpen(["rig:demo", "--json"], inApp, { status: 200, available: true });
expect(okAmbient.gets).toContain("/api/terminal/status?provider=herdr-app&appHome=%2Fapps%2Fh");
expect(okAmbient.posts).toEqual([{ path: "/api/terminal/open", body: { view: "rig:demo", provider: "herdr-app", herdrApp: { appHome: "/apps/h" } } }]);
expect(okAmbient.errs).toEqual([]);
// 2. Ambient + available:false -> desktop fallback, stderr + notes, no /api/terminal/open with herdr-app
const staleAmbient = await runOpen(["rig:demo", "--json"], inApp, { status: 200, available: false });
expect(staleAmbient.gets).toContain("/api/terminal/status?provider=herdr-app&appHome=%2Fapps%2Fh");
expect(staleAmbient.posts.some((p) => (p.body as { provider?: string })?.provider === "herdr-app")).toBe(false);
expect(staleAmbient.errs[0]).toContain("herdr-app's socket exists at /apps/h/herdr-app.sock but it isn't answering; opening a desktop window instead. Use --provider herdr-app to require it.");
const parsed = JSON.parse(staleAmbient.logs[0]!) as { notes?: string[] };
expect(parsed.notes).toContain("herdr-app's socket exists at /apps/h/herdr-app.sock but it isn't answering; opening a desktop window instead. Use --provider herdr-app to require it.");
// 3. Ambient + status route 404/4xx -> says the daemon could not confirm, not "isn't answering"
const unconfirmedAmbient = await runOpen(["rig:demo", "--json"], inApp, { status: 404 });
expect(unconfirmedAmbient.posts.some((p) => (p.body as { provider?: string })?.provider === "herdr-app")).toBe(false);
expect(unconfirmedAmbient.errs[0]).toContain("the daemon could not confirm whether it is answering (HTTP 404)");
expect(unconfirmedAmbient.errs[0]).not.toContain("isn't answering");
// 4. Explicit --provider herdr-app + available:false -> still herdr-app, fails loudly, no fallback
const explicitFlag = await runOpen(["rig:demo", "--provider", "herdr-app", "--json"], inApp, { status: 200, available: false });
expect(explicitFlag.gets).toEqual([]);
expect(explicitFlag.posts).toEqual([{ path: "/api/terminal/open", body: { view: "rig:demo", provider: "herdr-app", herdrApp: { appHome: "/apps/h" } } }]);
expect(JSON.parse(explicitFlag.logs[0]!)).toMatchObject({ ok: false, provider: "herdr-app", code: "herdr_app_unavailable" });
// 5. Ambient (no --provider) + --follow -> falls through to the existing Herdr follow path (never follow_unsupported)
const ambientFollow = await runOpen(["slice:s1", "--follow", "--json"], inApp, { status: 200, available: true });
expect(ambientFollow.gets.some((g) => g.startsWith("/api/terminal/status?provider=herdr-app"))).toBe(false);
expect(ambientFollow.posts).toEqual([
{ path: "/api/terminal/follow", body: { view: "slice:s1", provider: "herdr", enabled: true } },
]);
expect(JSON.parse(ambientFollow.logs[0]!)).toMatchObject({ ok: true, provider: "herdr", workspaceId: "ws-1" });
// 6. Explicit --provider herdr-app + --follow -> returns follow_unsupported
const explicitFollow = await runOpen(["slice:s1", "--provider", "herdr-app", "--follow", "--json"], inApp, { status: 200, available: true });
expect(explicitFollow.gets).toEqual([]);
expect(explicitFollow.posts).toEqual([]);
expect(JSON.parse(explicitFollow.logs[0]!)).toMatchObject({ ok: false, provider: "herdr-app", code: "follow_unsupported" });
// 7. Explicit --provider herdr-app + --window -> --window wins and opens a desktop window (never herdr-app)
const explicitWindow = await runOpen(["rig:demo", "--provider", "herdr-app", "--window", "--json"], inApp, { status: 200, available: true });
expect(explicitWindow.gets.some((g) => g.startsWith("/api/terminal/status?provider=herdr-app"))).toBe(false);
expect(explicitWindow.posts.some((p) => (p.body as { provider?: string })?.provider === "herdr-app")).toBe(false);
expect(JSON.parse(explicitWindow.logs[0]!)).toMatchObject({ ok: true, provider: "herdr" });
});
});
Loaded 100 of 538 files, more files were not shown because too many files have changed in this diff. Show more